Compare commits

..
Author SHA1 Message Date
Vitor PamplonaandGitHub 79f198c729 Merge pull request #3836 from vitorpamplona/feat/nip66-relay-monitor
NIP-66: measure relays from the traffic a client already makes
2026-07-31 23:23:23 -04:00
Vitor PamplonaandClaude Opus 5 18d229be58 Match the listener's parameter names; drop commas from test names
Native targets reject a comma inside a backticked name, so five tests
that read fine on JVM broke every Kotlin/Native build. Renamed without
them.

The override parameters now match RelayConnectionListener — pingMillis,
compressed, cmdStr, cmd, msg, errorMessage — which silences six warnings
and, more to the point, fixes a misreading: onConnected's second and
third parameters are the connection's ping and whether it is compressed,
and I had them named attempt and success.

Both were missed the same way: jvmTest passes without ever compiling the
native TEST sources. All five targets now compile, main and test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:55:36 -04:00
Vitor PamplonaandGitHub d6333989a4 Merge pull request #3837 from vitorpamplona/claude/quartz-hex-encode-decode-xggbv2
Add optimized decode64/decode128 and encode64/encode128 to Hex
2026-07-31 22:51:50 -04:00
Vitor PamplonaandClaude Opus 5 cf75272202 Fix the native build: toSortedMap is java.util
commonMain, so it compiled on JVM and broke every native target. Sorted
into a LinkedHashMap instead, which is the same output everywhere.

Found by CI on iosSimulatorArm64 because I had only compiled the JVM
target locally; all five now build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:31:39 -04:00
Vitor PamplonaandClaude Opus 5 5c24b003e7 NIP-66: measure relays from the traffic a client already makes
A monitor normally probes — opens connections purely to measure, then
throws them away. A client that is already subscribing, fetching and
publishing has better data for free: measured under real load, against
the relays it actually uses, at the concurrency it actually runs.

RelayObserver is a RelayConnectionListener, so it sees every connection
whichever code path opened it and none of them has to report anything:

  rtt-open      onConnecting to onConnected
  rtt-read      first REQ to its EOSE
  rtt-write     first EVENT to its OK
  reachable     it opened, or served something
  auth-required it sent AUTH, or CLOSED saying so
  the error, verbatim, when it never opened

Everything is OBSERVED. Nothing is copied from a relay's NIP-11: that is
the relay's own claim, available to anyone who asks, and republishing it
under a monitor's signature adds nothing but a chance to go stale. Where
the two disagree — a relay advertising open reads that then challenges
us — the observation is the half worth having, and copying the claim
would erase it. It also keeps quartz free of an HTTP dependency.

RelayMonitor is the whole wiring: construct one and connections are
measured, signed as 30166s on an interval, and folded into a cheap
in-memory isKnownDead for picking relays. That read has to be cheap — an
outbox picker runs per event — so it answers from a snapshot refreshed on
an interval, never a store query.

The signer is required. Measuring relay quality and letting others check
it IS NIP-66, and an optional signer would just add the failure mode this
library keeps designing out: configured, silent, doing nothing. A client
that should not publish does not construct one.

RelayObserver also replaces the CLI's RelayDiagnostics, which was the
same listener minus the timings. Porting it surfaced a bug both shared:
substringBefore(':') returns the WHOLE string when there is no colon, so
a relay's free-form CLOSED prose became its own tally key and the map
grew with the number of distinct sentences relays wrote. The colon is
now required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:21:00 -04:00
Claude 729fb1bc17 perf(quartz): hoist lookup tables in Hex.decode/encode/isEqual/readLong; bench new codecs
javap showed the hexToByte/byteToHex field re-loaded on every use inside
these methods (16 times per readLong call) — the JVM/ART doesn't reliably
prove the load loop-invariant. Hoisting it into a local measured ~25%
faster for decode and ~10% for isEqual and readLong on the JVM
(4096 random 32-byte ids, best-of-150 rounds, 3 repeats); encode was
neutral on HotSpot but is hoisted too since ART is historically worse
at this (see the internalIsHex comment).

Branchless variants of isHex/isHex64 were also measured and were a
wash-to-slightly-worse than the branchy early-exit versions on valid
input, so those keep their current implementations.

Also adds the new exact-size codecs to the on-device HexBenchmark
(decode64, decode64OrNull, encode64, decode128, encode128, toLong256,
and the old isHex64+decode two-pass for comparison) so ART numbers can
be collected with the existing benchmark harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 01:47:01 +00:00
Vitor PamplonaandGitHub 77ff9e9699 Merge pull request #3834 from vitorpamplona/dependabot/github_actions/actions-08295fc4ea
chore(actions): bump the actions group with 5 updates
2026-07-31 21:34:08 -04:00
dependabot[bot]andGitHub 166ebc755e chore(actions): bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `5.6.0` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.1` | `3.0.2` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |


Updates `actions/setup-java` from 5 to 5.6.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v5.6.0)

Updates `softprops/action-gh-release` from 3.0.1 to 3.0.2
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 00:45:45 +00:00
Claude d8bae8c628 perf(quartz): tune Hex.decodeExactOrNull at the bytecode level
javap on the previous version showed the hexToByte field re-loaded
twice per iteration, the trip count as a runtime parameter, and the
whole loop wrapped in an exception table (only needed because chars
above 0xFF overflow the 256-entry lookup table).

Now the table is hoisted into a local, the function is inline so the
32/64-byte length becomes a compile-time constant at each call site,
and out-of-range chars are rejected branchlessly: the index is masked
with 'and 0xFF' so it cannot overflow, while '255 - code' goes negative
for any char above 0xFF and is folded into the same sign-bit
accumulator that already catches invalid hex digits. No try/catch, no
exception table, no branches in the loop.

Measured on the JVM (4096 random ids, best-of-200 rounds, two runs
with variant order reversed to rule out JIT profile artifacts):
~25% faster than the previous version and ~2x faster than the
isHex64 + decode two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:33:51 +00:00
Claude 9ea4b81c1f feat(quartz): size-enforcing Hex.decode64/128 and encode64/128
Adds exact-size codec entry points to the Hex utility so 32-byte
pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the
wrong size or invalid characters are rejected instead of silently
decoded:

- decode64 / decode128 throw IllegalArgumentException; the OrNull
  variants return null for untrusted input.
- encode64 / encode128 require exactly 32 / 64 input bytes.

The decode is single-pass: character validation is folded into the
decode loop via a sign-bit OR-accumulator (the lookup table yields -1
for invalid chars), so it is faster than the isHex64 + decode
two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:14:17 +00:00
Vitor PamplonaandGitHub 5a433663fe Merge pull request #3831 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-31 20:00:11 -04:00
vitorpamplonaandgithub-actions[bot] 0b77de879d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-31 23:55:51 +00:00
Vitor PamplonaandGitHub 50e7f43b9a Merge pull request #3832 from vitorpamplona/feat/filter-purpose-explainer
feat(relays): explain why every subscription exists, and fix what that exposed
2026-07-31 19:52:39 -04:00
Vitor PamplonaandClaude Opus 5 9fe24be6d6 perf(relays): keep the EOSE cursor lock-free on the per-event path
The previous commit guarded EOSERelayList with a lock, which put the wrong path
under it. addOrUpdate runs on **every live event** — SubscriptionListener.onEvent
calls newEose for each one, hundreds per second across a few hundred relays — so
that was one monitor for every event arriving in the app.

Almost none of those events change the map. MutableTime exists precisely so a
known relay only bumps a Long inside its own entry; the map is structurally
written on the *first* frame from a relay, plus remove() and clear(). A couple of
hundred writes for the lifetime of the process, against millions of reads.

So the map is copy-on-write behind @Volatile: replaced wholesale under the lock,
never mutated in place after publication. Readers take nothing. The per-event
bump takes nothing. Only a relay's first frame pays, and it pays a map copy of a
few hundred entries, once.

The bump itself stays unsynchronized, which is deliberate: two socket threads
racing updateIfNewer can leave the older timestamp, and this value is a floor for
`since`, so losing a millisecond re-asks for a couple of events rather than
skipping any. Documented on the method rather than fixed with an atomic that
would cost a barrier per event.

Verified: iOS, JVM, Android, desktop and the full suite build and pass. Cold start
on emulator-5554 — no fatal exceptions, no ConcurrentModificationException, 0
nos.lol refusals, 182 relays connected and the purpose breakdown populated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:47:50 -04:00
Vitor PamplonaandClaude Opus 5 5fe33a8152 fix(relays): serialize writes to the single-sub EOSE cursor map
EOSERelayList backs SingleSubEoseManager with a plain mutableMapOf, and
addOrUpdate is called from SubscriptionListener callbacks — i.e. from each
relay's own socket-reader thread. A client holding a few hundred relays therefore
had that many concurrent writers to one unsynchronized map. EOSEAccountFast wraps
its lists in a lock for exactly this reason; a bare list handed to
SingleSubEoseManager had nothing.

The race predates this branch but the branch made it load-bearing: both merged
managers (notifications and account metadata, across every logged-in account and
every relay they read) now run through SingleSubEoseManager, and the EOSE refetch
fix added a second writer in remove(). Writes are serialized with KmpLock, the
same primitive ComposeSubscriptionManager uses.

Reads still go through the live map from since(), deliberately — but the two
merged managers no longer depend on that. They were reading `since` immediately
after clearing a relay and relying on the mutation being visible through it, so
hardening since() into a snapshot later would have silently disabled the refetch
with no test to notice. Growth now zeroes the cursor for that pass explicitly, in
addition to clearing it.

Verified: both iOS targets, JVM, Android, desktop and the full suite build and
pass; a cold start on emulator-5554 shows no fatal exceptions, no
ConcurrentModificationException, and 0 nos.lol refusals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:36:28 -04:00
Vitor PamplonaandClaude Opus 5 b8d0caa8ff fix(commons): import kotlinx.coroutines.IO so the iOS targets build
`Dispatchers.IO` is an internal member in common code; the public form on
Kotlin/Native is the `kotlinx.coroutines.IO` extension property. Without that
import the member resolves on JVM and Android and fails only on iOS, which is
precisely what commons' compile-only iOS spike exists to surface.

MergedTopFeedAuthorListsState moved into commonMain in 3f4723437e and was the one
file that came across without the import — the other 22 commonMain users of
Dispatchers.IO already have it, and the only other three files that mention it do
so in comments. One error, one file, one missing import.

Reproduced locally with :commons:compileKotlinIosSimulatorArm64 before fixing;
both iOS targets, JVM, Android and the full test suite pass after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:25:26 -04:00
Vitor PamplonaandClaude Opus 5 e3e17e8ecb fix(notifications): stop resetting the watchdog alarm on every account change
The service layers were being re-enabled level-triggered. Before this branch the
inner flow was a Boolean behind distinctUntilChanged, so enableServiceLayers()
ran once per real transition. It now carries (all accounts, participating
accounts), and Account has no equals — so the flow re-emits whenever the account
map changes identity, and the outer collector restarts on every
foreground/background crossing, which includes every screen-off.

That matters because ServiceWatchdogManager.schedule() calls setInexactRepeating
with FLAG_UPDATE_CURRENT and a first fire of `elapsedRealtime() + 5min`. Every
call replaces the alarm and pushes that first fire out again. Screen-on happens
far more often than every five minutes, so L5 — the layer whose entire job is
noticing a dead service and restarting it — would effectively never have fired.

NotificationCatchUpWorker was unaffected: it enqueues with
ExistingPeriodicWorkPolicy.KEEP, so repeat calls leave the existing period alone.

Enable/disable is edge-triggered again, explicitly this time rather than as a
side effect of what the upstream flow happens to emit.

Also drops ActiveSubscriptionsState.busiestPurposeFilters, which has no readers —
the cards draw their share against attributedFilters.

Verified on emulator-5554: after a cold start, HOME, and return, the foreground
service is still running and the watchdog alarm is still registered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:54:46 -04:00
Vitor PamplonaandClaude Opus 5 a295f31f94 refactor(relays): mount the Cashu wallet like every other account subscription
The wallet was the only account-level subscription whose lifetime was decided by
the model. CashuWalletState collected relay sets on the Account's own scope and
called subscribe/unsubscribe itself, so it ran for every Account object that
happened to be resident — including accounts loaded purely so pushed gift wraps
could be decrypted by their owner, which have no wallet anyone is looking at.

Gating that with a subscribedAccounts flow made it worse: a model object read the
relay layer's bookkeeping ("is this pubkey REQ-ing anywhere?") to decide whether
to talk to relays, and encoded a proxy for the rule rather than the rule. It
happened to work only because the registry mounts every account in the
foreground.

CashuWalletEoseManager is a PerUserEoseManager in the account group, exactly like
NwcNotificationsEoseManager already was. Start and stop now come from the same
mounts as notifications, DMs and gift wraps — the screen's for the account on
show, the registry's for the rest — and will follow whatever the
foreground/background rule becomes without knowing about it. Per user, never
merged: each wallet reads its own outbox for its own events and its own inbox for
nutzaps addressed to it.

commons keeps the query shape as a plain cashuWalletFilters() function and loses
the CashuWalletFilterAssembler wrapper. subscribedAccounts disappears entirely —
from Account, AccountCacheState, AppModules, both AccountViewModel previews, both
androidTests, and AccountFilterAssembler itself.

Verified on the wire, not just on the screen: with REQ logging temporarily on,
134 REQ frames carried kind 17375 across 9 relays naming all 4 logged-in accounts,
and 90 carried kind 9321. The subscriptions screen agrees — Wallet and Nutzap
Inbox under each of the four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:42:25 -04:00
Vitor PamplonaandGitHub 7e9abd86b3 Merge pull request #3833 from vitorpamplona/claude/crypto-security-review-f13uy2
Fix SharedKeyCache hash collision vulnerability & add constant-time MAC comparison
2026-07-31 18:13:57 -04:00
Vitor PamplonaandClaude Opus 5 19a0088e36 fix(relays): refetch when a merged subscription gains an account
A merged filter keeps one EOSE cursor per relay. That is right while the set of
accounts it covers is stable and silently wrong the moment it grows: the account
that joins inherits a cursor it never earned, so `since` skips everything older
and its history is never requested. The subscription looks healthy, reports EOSE
and delivers new events forever after — it just never backfills.

This is the normal startup path, not an edge case. The account on screen mounts
from Compose and EOSEs within a second or two; the registry brings the rest in a
moment later, onto relays that have already reported EOSE. Account switching and
mid-session login hit it the same way.

It bites metadata harder than notifications. Notifications have a backward pager
that is deliberately still per-account and can recover the gap. Metadata has
none, so an account that joined mid-session would go without its own profile,
follows and lists until the next launch cleared the in-memory cursor.

MergedAuthorTracker records which accounts each relay's filter last covered and
reports growth; both merged managers drop that relay's cursor when it grows, so
the next filter asks from scratch. Growth only: an account leaving takes nothing
with it, and the accounts that remain already have their events.

Verified: MergedAuthorTrackerTest covers first-sight, join, no-op, leave, swap,
per-relay independence and clear — mutation-checked by making the predicate
return false, which fails three of the seven. On emulator-5554 all four accounts
keep their Notifications, DM Inbox and Account's data, and nos.lol refusals over
three clean cold starts were 0/0/7 against 8 before, so the extra refetch costs
nothing measurable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:56:19 -04:00
Vitor PamplonaandClaude Opus 5 903bf11abf refactor(relays): audit cleanups on the subscription work
Three findings from reading the branch end to end:

- AccountNotificationsEoseFromInboxRelaysManager carried a `pubkeyOf` helper with
  no callers and a comment inventing a reason for it. Removed.
- `limit = 1 * pubkeys.size` said the multiplication out loud for no reason.
- The two merged managers scale their limits differently and nothing said why.
  Metadata multiplies by account count, notifications does not, and that is
  deliberate: metadata is replaceable so its limit is a safety bound, while
  notifications are a stream whose limit is a page size — scaling would ask 4x
  the data on every cold start and relays clamp it anyway. Documented, along with
  the consequence (a busy account can crowd a quiet one out of the shared
  newest-N) and what recovers it (the history pager, left unmerged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:12:36 -04:00
Vitor PamplonaandClaude Opus 5 7fb99842a8 fix(relays): FOLLOW_LISTS does not run in the background
Every producer of FOLLOW_LISTS is a browse filter on the Follow Sets feed —
FilterFollowSetsBy{Authors,Community,AllCommunities,Geohash,Hashtag} and Global,
all under discover/nip51FollowSets. Nothing account-level assigns it: the
account's own follows ride ACCOUNT_DATA with the rest of its lists. So the
`runsInBackground = true` described a producer that does not exist.

The flag has no code consumer — isWorthNamingInNotification keys off `group`, not
this. It is read by whoever is looking at a backgrounded breakdown and asking
which of these purposes is allowed to be there, and the enum says a
`runsInBackground = false` purpose appearing while backgrounded is a leak. A
browse feed claiming to be allowed is exactly the entry that would get waved
through.

Nothing behaves differently. It stops the taxonomy lying to the next person who
uses it to diagnose one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:59:04 -04:00
Claude a59bc6d82d refactor(nip44): use a content-addressed key object for the shared-key cache
Replaces the hex-string cache key with a small CacheKey holding the raw
(privateKey, pubKey) references — no byte copy and no ~400-byte hex String per
lookup. The precomputed Int hash is only a bucket selector; equals() does the
authoritative full-content comparison, so collisions share a bucket and are
disambiguated rather than returning the wrong peer's secret. Same value-type
immutability contract already relied on by X25519KeyPair as a map key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
2026-07-31 20:13:59 +00:00
Claude e94b8eaf3b fix(nip44): content-address shared-key cache and use constant-time MAC checks
Two hardening fixes in the NIP-44/NIP-04 encryption primitives:

1. SharedKeyCache keyed conversation/shared secrets by a 32-bit polynomial
   hashCode of (privateKey || pubKey). Distinct peers whose bytes hash to the
   same Int collided on one LRU slot, so get() could return one peer's key for
   a message meant for another — a silent wrong-key encrypt/decrypt. The hash
   collides independently of the private key, so a pubkey aliasing a victim's
   contact is grindable. Now keyed on the full (priv || pub) byte content.
   Added SharedKeyCacheTest, which fails on the old hashCode key and passes now.

2. NIP-44 MAC verification (Hkdf.fastExpand and Nip44v2.checkHMacAad) used
   ByteArray.contentEquals, which short-circuits on the first mismatching byte
   and leaks a timing side channel on the HMAC tag. Switched to a shared
   equalsConstantTime helper (utils/ConstantTime.kt), matching the constant-time
   tag checks already used in ChaCha20Poly1305/XChaCha20Poly1305/MLS.

Also corrects the X25519 KDoc, which claimed the JVM/Android actual delegates to
java.security XDH; it is in fact the same pure-Kotlin Montgomery ladder as the
other targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
2026-07-31 20:00:07 +00:00
Vitor PamplonaandClaude Opus 5 d7d1b052fe fix(relays): file the account's own contact cards under account data
"Observing Profiles" showed up for accounts that had no screen at all, which is
the one thing that purpose is explained as never being: "profiles of the people
currently on screen".

ContactCardFilters has two builders sharing SubPurpose.PROFILE_METADATA, and only
one of them observes anything. filterContactCardsToTargetKeysFromTrustedAccounts…
fetches kind:30382 cards ABOUT the users on screen — that one is right.
filterContactCardsByAuthorInTheRelay fetches the account's OWN nicknames in the
login-time bulk download, from the account's own relays, with nobody on screen;
its own KDoc said as much while the purpose said otherwise.

It rides filterAccountInfoAndListsFromKey, so every logged-in account carried one
of these per relay — Dr Martha Liz's two relays showed as "Observing Profiles ·
2 filters · 2 relays" for an account nobody was looking at. It is account data,
and now says so.

Verified on emulator-5554: only the account on screen still reports Observing
Profiles; the other three carry Account's data alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:54:29 -04:00
davotoulaandClaude Opus 5 d9e01c8504 i18n: translate missing amethyst strings for cs, de, sv and pt-BR
Fills the on-disk gaps in the four target locales: channel archive/unarchive,
the new-forum title, the External Content screen title, the archived channel
section header, the three share targets (picture / short / video) and the URL
preview's open-in-browser action.

Wording follows each locale's existing siblings rather than being translated
fresh -- url_preview_open_in_browser reuses the string already in
git_repo_open_in_browser, share_target_as_picture follows new_picture, and
share_target_as_short_video follows home_content_type_shorts.
relay_group_section_archived heads a channel list, so cs/sv/pt take the plural
adjective.

Keys whose translation would be byte-identical to the English source are left
out on purpose: Crowdin strips source-identical entries on export and Android
already falls back to values/strings.xml at runtime. That covers the "workflow"
loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos /
Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR.

The commons composeResources tree was already complete for all four locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-31 21:42:30 +02:00
Vitor PamplonaandGitHub 5231f5b051 Merge pull request #3826 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-31 14:05:43 -04:00
Vitor PamplonaandClaude Opus 5 e1404f4d6c fix(relays): drop "Your" from the account data label
The row sits under an account header that already names whose it is, and with
several accounts on screen "Your Account's data" repeated under someone else's
name reads as a contradiction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:05:26 -04:00
Vitor PamplonaandClaude Opus 5 15f8b7713e fix(relays): re-attribute account data after the metadata merge
"Your Account's data" fell into "Not attributed" one commit ago. None of the
metadata builders ever named their own account — they relied on
PerUserEoseManager stamping `attributedTo(pk)` over whatever they returned. Moving
the manager to SingleSubEoseManager took that away, because that base class
cannot attribute: one of its filters may serve several accounts, so there is no
single pubkey to stamp.

So the builders name themselves now, which is where the knowledge actually is.
Every filter in this package fetches an account's OWN profile, lists, bookmarks
and recent posts, so `authors` and the accounts asking are the same list.

The one exception is filterBasicAccountInfoFromKeys, which fetches OTHER people's
profiles for the account-switcher avatars: there the authors are precisely not
the requester, so it takes `requestedBy` and is attributed to that instead. Its
parameter carries the reason, since it reads like an inconsistency otherwise.

Notifications were unaffected — those builders already set accountPubKeys
themselves, which is why only this purpose lost its account.

Verified on emulator-5554: "Your Account's data" appears under all four accounts
again, and the unattributed count is back to 8 filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:48:23 -04:00
Vitor PamplonaandClaude Opus 5 fe2d7ef045 feat(relays): merge account metadata across accounts into one REQ
Account metadata was the largest single contributor to blowing a relay's
subscription cap: seven filters in a subscription, repeated once per logged-in
account. Every one of them is `authors`-keyed, so a relay that several accounts
read from can be asked about all of them by widening `authors` — the same shape
of merge the notification tail just took, and for the same reason.

The per-account `limit`s are summed rather than shared. These are mostly
replaceable events, so the limit is a safety bound rather than a page size, and
scaling it by the number of accounts leaves each one exactly the headroom it had
alone. That is the difference from gift wraps, which stay per-account because
their `limit` IS a page size over unsolicited content.

Measured on emulator-5554, four accounts, cold start, counting nos.lol's
`ERROR: too many concurrent REQs`: 13 before either merge, 11 after
notifications, 8 after this. The subscription count on that relay went from
24-26 to 23, against its cap of 20.

So the per-account multiplication is no longer the driver, and the remaining 23
are not account-level at all — they are the feed, channel and finder assemblers.
Cutting further means looking there, not at more merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:56:03 -04:00
Vitor PamplonaandClaude Opus 5 24decefd4b feat(relays): merge the notification tail across accounts into one REQ
Every account-level loader is a PerUserEoseManager, which opens one subscription
per user. With four accounts open that multiplies, and shared relays run out of
room: nos.lol (strfry, `max_subscriptions: 20`) answered `ERROR: too many
concurrent REQs` — 0 times before this branch, 7 with three accounts subscribed,
13 with four. The refusal arrives as a NOTICE, which carries no subscription id
and never reaches RelayReqRefusals (wired only to CLOSED), and "too many
concurrent REQs" matches none of its markers. So the relay drops those REQs while
we still believe they are live: they never EOSE and never deliver.

Notifications are `#p`-scoped, so a relay serving several accounts can be asked
about all of them in one filter naming every pubkey — same query, wider tag. The
manager moves to SingleSubEoseManager: one REQ per relay instead of one per
(account, relay), with the `since` floor taken per relay as the OLDEST of the
participating accounts' floors so widening for one can never cut another short.

Gift wraps deliberately do NOT merge. They are unsolicited and opaque to the
relay, so it cannot rate-limit them per recipient; a merged query would let one
spammed account eat the shared `limit` and starve every other account's DMs.
Each account keeps its own budget there.

A merged filter serves several accounts, so accountPubKey becomes accountPubKeys.
The subscriptions screen shows such a filter under each account it serves —
"why is this relay busy for me" has to be answerable per account — which makes
the per-account counts a breakdown of a shared filter rather than a partition of
the total. attributedFilters is that sum, and is what a card's share is drawn
against now; dividing by the wire total would read as 100% for each of two
accounts sharing one filter.

Measured, and it is only part of the answer: nos.lol carries 24-26 subscriptions
against its cap of 20, and this removes 3 of them. The rest are the other
per-account managers — account metadata alone is 7 filters in a subscription per
account, and gift wraps another. Merging metadata (it is `authors`-keyed and
merges the same way) is the next lever; this commit does not get us under the cap
on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:09:20 -04:00
vitorpamplonaandgithub-actions[bot] 0f4580ab91 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-31 14:58:45 +00:00
Vitor PamplonaandGitHub cdef4e9658 Merge pull request #3830 from vitorpamplona/claude/servicetype-tag-bounds-check-533b4r
Fix ServiceType parsing to handle edge cases safely
2026-07-31 10:56:02 -04:00
Claude 51e388d2a1 fix: bounds-check ServiceType parsing of NIP-85 service tags
ServiceType.parse destructured the result of split(":", limit = 2) into
two components, so any value without a colon (e.g. the "client" of a
["client", "nostria"] tag) threw IndexOutOfBoundsException instead of
returning null. It also accepted "30382:" as an empty service type.

ServiceType.isOfKind had the same class of bug: it read
serviceType[kind.length] after startsWith, which is out of bounds when
the value equals the kind exactly ("30382" vs "30382").

Parse the separator by index and check the length before indexing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112ysMEzSaezH9mXFteNt13
2026-07-31 14:54:13 +00:00
Vitor PamplonaandGitHub 9a5d47004c Merge pull request #3827 from davotoula/fix/location-foreground-gate
Listen only while foregrounded, and fix the meter that measures it
2026-07-31 10:21:07 -04:00
Vitor PamplonaandGitHub 731f4369cb Merge pull request #3828 from vitorpamplona/claude/content-parse-error-a1pf9h
Fix metadata JSON parsing to use lenient parser consistently
2026-07-31 10:14:56 -04:00
davotoula d93f4db451 docs(location): record the clean-install smoke run, closing R1 2026-07-31 15:57:04 +02:00
Vitor PamplonaandClaude Opus 5 61611bdc48 feat(subscriptions): every account pulls while a screen is up
"Keep this account active in the background" gated subscriptions everywhere,
not just in the background. An account you had not opted in for showed no
notifications and no DMs even with the app open in front of you — you had to
switch to it and wait for its subscriptions to mount from scratch. The setting's
name only ever promised something about being away.

So the rule now matches the name. While any activity is STARTED, every loaded
account pulls its own notifications, DMs and gift wraps: the user can switch
accounts at any moment and expects the one they land on to be current, and this
costs nothing once the app is away because it ends with the screen. When the app
goes away, the set narrows to the accounts that opted in — which is the only
thing the flag decides now.

The service layers stay where they were, gated on the master switch AND somebody
having opted in. A foreground-only account must never start a foreground service
that outlives the screen that wanted it, so those two questions are answered from
one snapshot of accounts + flags rather than two.

The registry loses "Background" from its name along with the assumption: it
mounts exactly the set it is handed and decides nothing, so the rule lives in one
place.

Verified on emulator-5554 with 4 loaded accounts, 1 opted in. Foreground: 4
mounted, and the account that had no section on the subscriptions screen at all
now shows Notifications 8 filters / 2 relays plus DM Inbox. On HOME: 3 mounted
(-1), releasing the one that never opted in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:56:01 -04:00
Claude 50e61901f7 fix: read profile json with the same lenient parser that renders it
contactMetaData() decodes kind-0 content with the lenient JsonMapper, but
contactMetadataJson() ran the strict default parser. Profiles that sit in
that gap (bare keys, unquoted values) rendered fine yet read back as null,
so updateFromPast() started from an empty map and silently dropped every
field Amethyst does not edit itself the next time the owner touched their
profile — the exact thing its "tries to not delete any existing attribute
that we do not work with" contract promises not to do.

Both accessors now use JsonMapper.jsonInstance. The lenient reader tags
unquoted tokens as strings, so re-encoding still emits valid JSON.

Also pins the behaviour of two malformed kind-0s seen in the wild — a
JavaScript object literal and a value truncated by stray quotes. Neither
is recoverable by any parser; the test records that they are dropped with
a warning and never throw.
2026-07-31 13:38:04 +00:00
Vitor PamplonaandClaude Opus 5 f2895b2d29 fix(relays): count filters once per filter, not once per entity it names
The Active Subscriptions screen reported two different things as "filters".
The header counted filters; a purpose card summed its per-entity rows. Those
only agree when every filter names exactly one entity — and the busiest ones
name many, because batching is the whole point of them: one `#e` filter per
relay carrying every followed chat, one `#d` filter per host relay carrying
every joined group.

So six chats on six relays read as 144 filters where 24 were on the wire, and
"8% of all" divided the inflated number by the real one. The screen exists to
answer "why do I have this many subscriptions", and it was overstating its
loudest purposes by exactly their batching factor — the opposite of the job.

A purpose now tallies each filter once as it is scanned, before the fan-out.
The per-entity rows stay, because "which chats is this relay serving" is worth
seeing, but they are a breakdown rather than a total: the field is renamed to
namedInFilters and says in its docs that it is not summable.

The aggregation moves out of the ViewModel into a pure aggregateSubscriptions()
so the invariant is testable without a relay pool. AggregateSubscriptionsTest
pins it on the reported shape, and was mutation-checked: restoring the old
`entityRows.sumOf { … }` fails three of its four cases, the fourth being the
relay count, which never depended on it.

On device, Public Chat goes from 144 filters over 6 relays to 18 over the same
6, and Relay Groups from 116 to 96. No filter changed; only the arithmetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:31:17 -04:00
davotoula 2360829e83 Code review:
- refactor(location): dedupe test fixtures and simplify the gate internals
- test(location): assert the release, not just its ledger side-effect
- test(location): cover the LocationState -> LocationFlow -> RefCountedSession seam
2026-07-31 15:19:44 +02:00
davotoula f09eef1470 feat(amethyst): wire the location foreground gate and refcounted meter 2026-07-31 10:26:28 +02:00
davotoula 78f0583b16 test(amethyst): document LocationStateTest deviations, close profile gap
feat(amethyst): gate location listening on foreground
2026-07-31 10:26:28 +02:00
davotoula 4234c4f45b docs(location): cite the proven mechanism for LocationFlow's try/finally
fix(test): close the seed-after-registration ordering gap in LocationFlow

fix(test): replace non-discriminating LocationFlow cleanup test
2026-07-31 10:26:28 +02:00
davotoula a742cec495 feat(amethyst): register one location provider with a paired listening hook
feat(amethyst): add location provider ladder
feat(amethyst): add RefCountedSession for overlapping ledger holders
2026-07-31 10:24:39 +02:00
davotoula 3fe936bedb docs 2026-07-31 10:24:16 +02:00
Vitor PamplonaandClaude Opus 5 8e019a2d59 feat(relays): explain discovery filters by the feed selection behind them
Commit 4f1bd6e0c2 left the six public-chat discovery producers untagged and
justified it as "they search for chats rather than serving known ones, so there
is no entity to name". The first half is right and the conclusion does not
follow: having no entity is not the same as having no explanation. Every one of
those filters is built from a top-nav selection — Global, your follows, a
hashtag, a geohash, a community — which was known where the filter was built and
simply had nowhere to travel. The screen could only render them as "All", which
is the one thing they are not.

3f4723437e already moved the 25 top-nav value types into commons for exactly
this, so ExplainedFilter now carries the scope. It carries the per-relay value
rather than the whole set: the filter is already scoped to one relay, so it
holds only the slice that applies to it and no reference to the other relays'
authors. It stays a typed value rather than a formatted string because
purposeDetail already taught that lesson — text built in commons can never be
translated, so the UI matches on the type and picks its own wording.

scopedTo() stamps it at each feed's make…Filter dispatch, the last place that
still knows the selection; below it the builders have flattened it into
authors/#t/#g and it is unrecoverable. IFeedTopNavPerRelayFilterSet grew
scopeFor(relay) so that stamping is compiler-enforced across all 11 sets rather
than a type-switch that silently misses the next one added.

The screen groups these rows by scope *type*, not contents: an author-based
selection sends a different slice of the follow list to every relay, so keying
on contents would shatter "People you follow" into one row per relay — the
opposite of what the screen is for.

ExplainedFilterTest had not compiled since 4d53bbea9e renamed entityId to
entityIds, because `./gradlew test` does not run :commons:jvmTest. Repaired, and
extended to pin the new field: the scope is a slice of the user's follow list or
their chosen hashtag, and handing a relay the selection rather than the authors
it already sees would tell it which of its neighbours' filters belong together.

Verified on emulator-5554: Home Feed's row now reads "People you follow" with
its 176 relays, as one row rather than 176. The public-chat discovery producers
take the identical path but only mount while the Discover→Chats screen is open,
which this device's bottom nav has no tab for, so that specific row is unproven
on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:20:41 -04:00
Vitor PamplonaandClaude Opus 5 548a5c979e feat(subscriptions): subscribe every background account, not just the active one
An account that opted into "keep this account active in the background" got
nothing from that setting but a running service. Subscriptions were only ever
created from a composable holding an AccountViewModel, so the account on screen
was the only one talking to relays; the rest were loaded purely so pushed gift
wraps could be decrypted by their owner. On a device with no push — no Play
Services, no UnifiedPush distributor, no Pokey — those accounts pulled nothing
at all, and the setting quietly meant less than its name.

BackgroundAccountSubscriptionRegistry mounts the always-on loaders for each
participating account directly, with no view model behind them.
AlwaysOnNotificationServiceManager already watched the two flags that define
participation (the account's own toggle, or its NIP-46 signer), so it now keeps
the set instead of collapsing it to "is anyone participating" and hands it to
the registry on every change.

Running headless meant the key had to stop assuming a screen. AccountQueryState
drops to what an Account alone can supply, and AccountUiQueryState adds the feed
states for screens — the only always-on reader is the notifications cold-start
floor, which reads a feed nothing fills without UI and so could only ever be
null for these accounts. An account can now be mounted twice, on screen and in
the background; the managers dedup by user but keep whichever key arrived first,
so preferredKeys picks the screen's key and the account being looked at keeps
its floor rather than losing it to a race.

The Cashu wallet had the mirror-image bug: it subscribed from every Account's
own scope, ungated, which put a Wallet and a Nutzap Inbox on the wire for every
saved account — including accounts that never opted in and have no wallet. It
now follows AccountFilterAssembler.subscribedAccounts, the same truth both mount
paths write to.

Verified on emulator-5554 with 4 loaded accounts, 3 participating: each of the
3 gets its own Notifications and DM Inbox (12 and 3 filters for a background-only
account), filters scale per account while relays overlap, and the 4th account —
loaded but not participating — now holds no subscriptions at all where it
previously held Wallet and Nutzap Inbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:31:58 -04:00
Vitor PamplonaandClaude Opus 5 54afbf95e7 docs(notifications): describe the cold-start floor as what it is
The comment called it a backward-paging boundary that asks for everything
older than the feed's oldest card. It is neither: backward paging lives in
AccountNotificationsHistoryEoseManager, and `since` means newer-than, so
it floors the query at the depth the feed already reaches instead of
asking all-time again. It is also read only until a relay has an EOSE.

Records that it is always null for an account with no UI, since nothing
fills that feed — which is what makes the field inert on a headless path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:58:05 -04:00
Vitor PamplonaandClaude Opus 5 3f4723437e refactor(commons): move the top-nav per-relay filter values out of the app
The topNavFeeds package holds two layers. The value layer — the per-relay
filter sets, plain `Map<relay, filter>` of authors, hashtags, geohashes —
is pure data. The resolution layer around it (TopNavFilter, FeedFlow, the
loaders and decryption caches) evaluates feeds against the live event
graph and needs LocalCache, NoteState and the outbox loaders, so it is
app-coupled by nature and stays put.

Moving the 25 value types lets commons describe a feed selection without
depending on the app, which is what an ExplainedFilter needs if it is to
carry the top-nav scope instead of a flattened List<HexKey>. Desktop gets
them too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:14:15 -04:00
Vitor PamplonaandClaude Opus 5 4f1bd6e0c2 feat(relays): name which public chats each subscription serves
Public Chat collapsed into a single unnamed row because none of its
producers carried an entity id, so the screen could say how many filters
were running but not which chats caused them.

The six channel-scoped producers now tag their channel ids — including
the batched ones, which carry every channel a relay serves so the screen
fans them into a row each.

The six discovery producers (global, by author, by community, by hashtag,
by geohash) stay untagged on purpose: they search for chats rather than
serving known ones, so there is no entity to name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:05:57 -04:00
Vitor PamplonaandClaude Opus 5 5f6d09425d feat(notifications): open the subscriptions screen from the ongoing notification
Tapping the always-on notification landed on whatever tab was last open,
which does not answer the question that notification raises. It now
deep-links to Active Relay Subscriptions via an `activesubs` route, and
the screen gets its own entry in All Settings.

The expanded breakdown also held its last value across reconnects. It is
derived from the *connected* relays, so a drop to zero — the "connecting"
state — emptied it and collapsed the expanded view to a single line
exactly when someone was most likely reading it. What each connection is
for does not change while it re-establishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:00:29 -04:00
Vitor PamplonaandClaude Opus 5 0ad4726d41 feat(relays): name the things each subscription is for, not just their ids
Entity rows resolved to a short hex whenever LocalCache could not place
the id, which covered Marmot groups, Concord communities and geohash
cells — the three that most needed naming.

Each now resolves through the path its own subsystem uses: Marmot reads
the per-account chatroom StateFlows and its encrypted Blossom avatar,
Concord reads the folded Control Plane keyed on the session revision, and
geohash cells go through the app's reverse-geocode cache so a cell reads
as a place. All three route to their screen on tap.

Relay groups pivot on the host relay, expandable to the groups it carries:
a group is keyed by (id, host relay) and one community relay routinely
hosts many, so the flat list repeated one hostname seven times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:00:18 -04:00
Vitor PamplonaandClaude Opus 5 4d53bbea9e fix(relays): attribute every subscription to the account that asked for it
Filters carry an accountPubKey so the relay screens can group by account,
but attribution only happened for keys implementing AccountScopedQuery —
and ~50 query states held an `account` without declaring it, so the cast
failed silently and their filters showed as unattributed.

Two of the gaps were real bugs rather than display issues:

- CashuWalletFilterAssembler took `keys.first().pubkey` while flat-mapping
  every account's relays, so with two wallets logged in the second was
  never subscribed and its inbox relays were queried for the first
  account's nutzaps. Now built per account.
- UserReportsSubAssembler unioned every account's follow list into one
  per-relay map, asking one account's follows of another's outbox relays.
  Now one pass per account.

Where a subscription genuinely pools accounts (outbox discovery, on-screen
event watching, profile metadata), it is attributed only when a single
account is asking rather than inventing an owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:00:03 -04:00
Vitor PamplonaandClaude Opus 5 b6808959cd refactor(relays): split the public-chat purpose into the five protocols it held
PUBLIC_CHATS was a catch-all carrying NIP-28 chats, NIP-29 relay groups,
ephemeral chats, geohash chats and live-stream chat under one row labelled
with the NIP-29 name — so the subscription screen could not say where any
of them came from, and the label was wrong for four fifths of what it
counted.

Each is now its own purpose with its own label and explainer. Geohash
cells carry their id too, so location chats list one row per cell and
render as a place name instead of a single opaque group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:59:48 -04:00
Vitor PamplonaandGitHub 7cece7eafb Merge pull request #3825 from vitorpamplona/claude/intent-receivers-feed-sharing-rkwiv1
Add media share targets for Pictures, Shorts, and Video feeds
2026-07-30 12:22:44 -04:00
Claude dcf2807e75 fix: address audit findings on the media share targets
- Correct the KDoc on the Shorts and Longs composers. They claimed
  everything posted from them lands in that feed; VideoPostKind only
  governs videos, and the gallery picker takes images with no mime
  filter, so a picked JPEG still posts as a kind-20 picture that
  neither feed reads. Say so instead of asserting a false invariant.

- Forward the shared text as the composer's caption. The media targets
  dropped EXTRA_TEXT entirely, so sharing a photo with a caption lost
  it — while the DM target kept it. The routes now carry the message
  and NewMediaModel.load() seeds the caption field from it.

- Accept SEND_MULTIPLE on the three media targets. Sharing several
  files at once previously did not offer Amethyst at all, even though
  the picture composer publishes N images as one kind-20 event. Routes
  carry a URI list; other targets take the first.

- Replace, rather than stack, a feed entry when a second share arrives
  while the first is still open. Only entries that carry attachments
  are replaced, so a feed reached from the bottom bar keeps its
  tab-root marker underneath.

- Rename NewImageButton to NewVideoFeedButton: it is the Video feed's
  composer and handles pictures and video alike.

Adds ShareTargetManifestTest, which pins the activity-alias names in
AndroidManifest.xml to the constants ShareIntentRouting matches them
by — in both directions, plus the SEND_MULTIPLE filters. That link is
invisible to the compiler and fails silently at runtime by routing a
share to the wrong composer. Verified the guard bites by renaming an
alias in the manifest alone: three of its four tests go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
2026-07-30 16:07:52 +00:00
Vitor PamplonaandClaude Opus 5 1e20e10497 fix(notifications): sample the straggler relays instead of watching all of them
The probe that catches mentions delivered to the wrong relay was subscribing to
every relay the user's follows post to — ~330 relays, ~670 filters held
permanently, by a wide margin the largest thing the client ran. Despite the class
name nothing about it was random and nothing bounded it; `updateFilter` returned
`followsPerRelay.keys - notificationRelays` in full, and a grep for
take/shuffled/random/subList across the manager found nothing.

It now watches a rotating window of 5 relays that slides every 5 minutes, so the
whole space is still swept, just never all at once. That matches what the job
actually needs: a background sweep for misdelivered mentions, while the inbox
relays carry the real traffic.

The window is a contiguous slice of a URL-sorted list, not a fresh random draw.
Re-picking at random on each invalidation would re-REQ a different set every few
seconds — the manager already invalidates on follows changes (debounced 5s) and
notification-feed updates (sampled 5s) — which would cost more than the
subscriptions it replaced. Deterministic order means the window only moves when
the rotation timer says so. The rotation job is registered alongside the existing
two and cancelled with them in endSub.

Measured on device, cold start, same account:

    before   NOTIFICATIONS  670 filters  168 relays
    after    NOTIFICATIONS   58 filters   13 relays     (8 inbox + 5 sampled)

The Active Subscriptions explainer was updated in the same commit: it described
the old behaviour, and an explainer that lies is worse than none.

HOME_FEED is now the largest at 2,485 filters across 355 relays. That is the
outbox model working as designed rather than an anomaly, but it is the next thing
worth looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:33:48 -04:00
Vitor PamplonaandClaude Opus 5 a9ced24eb9 feat(relays): add an Active Subscriptions screen
Replaces the per-relay purpose chips with a screen whose only job is to answer
"why do I have this many subscriptions right now".

The chips were the wrong shape. Pivoting on relay hides the thing worth finding:
the notifications straggler probe holds ~670 filters across 168 relays, and on a
relay-shaped list that is one unremarkable chip repeated on 168 rows. Pivoted on
purpose it is a single line that dwarfs everything under it, which is exactly how
it was spotted in the first place.

Account is the outer grouping — several accounts are normally logged in, they do
not share relay sets, and a mixed total cannot be acted on. Purposes sort by
filter count, expand to per-entity rows, and carry an explainer describing the
actual strategy rather than the intent. Those explainers are written from the
code they describe: MODERATION says it asks each relay your follows publish to
because UserReportsSubAssembler walks declaredFollowsPerOutboxRelay, and
NOTIFICATIONS mentions the follows-wide probe because
AccountNotificationsEoseFromRandomRelaysManager subscribes to every follows relay
with no sampling.

Names resolve at render time from LocalCache and fall back to a short id — a name
captured when the filter was built would usually be missing (profiles arrive
later) and would go stale on rename.

Untagged filters are counted and shown rather than hidden. A total that claims to
be fully attributed when it is not would defeat the point of the screen.

Reached from the Connected Relays list, which is where the question occurs to
people. Notification filters now carry accountPubKey so the largest purpose
groups correctly; the remaining assemblers still report under "Not attributed to
an account" until they are threaded through.

Counts use two separate plurals composed at the call site rather than one string
with two %d, so "filter" and "relay" decline independently.

NOT visually verified: reaching the screen needs drawer navigation that was not
worth scripting. Compile-clean, tests green, and it reads the same
activeRequests data already verified on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:25:40 -04:00
Vitor PamplonaandClaude Opus 5 e47a2376aa feat(relays): carry entity and account on tagged filters
Adds entityId and accountPubKey to ExplainedFilter so a chip can answer "which
community made me connect here, and for whose account" rather than a bare
"Communities". Both ride copy() and are covered by the existing
never-reaches-the-wire test.

Ids, not names: names change, are often not loaded when the filter is built, and
would pin a stale copy into a subscription that outlives them. The UI resolves
against LocalCache at render time.

A pubkey, not an Account reference: filters are held by the relay pool for the
session and outlive the objects that built them, so a reference would keep a
logged-out account alive. Several accounts are normally active and do not share
relay sets, so without this one account's communities read as another's.

purposeEntities() returns the (purpose, entity, account) rows a relay is serving,
which is what a per-relay or per-subscription screen needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:13:10 -04:00
Claude ea2472468b feat: share images, shorts and videos straight into their feeds
Adds three share targets next to "New Post", "Send as DM" and
"New Highlight":

- New Picture (image/*) -> the picture feed's composer, publishing a
  NIP-68 kind 20 picture post
- New Short (video/*)   -> the Shorts feed's composer, publishing a
  NIP-71 kind 22 short
- New Video (video/*)   -> the Video feed's composer, publishing a
  NIP-71 video event

Until now every SEND intent landed in the kind-1 composer, so a shared
picture became a text note with a link instead of a post in the feed
the user was aiming for.

Each alias resolves to MainActivity like the existing ones, so
ShareIntentRouting now maps the launching component class to a
ShareTarget enum instead of a growing chain of isShareAsX() booleans.
The media targets navigate to the destination feed carrying the shared
content URI, and the feed's existing composer button opens on it.

Video kind is no longer guessed from orientation alone in feeds that
only read one of the two kinds: the Shorts composer always publishes
kind 22 and the Longs composer always publishes kind 21, so a post
lands in the feed it was composed from whatever the footage's shape.
The mixed Video feed and the picture feed keep the automatic choice.

Also fixes the New Post launch path passing the literal string "null"
as the attachment when a share carried no media.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
2026-07-30 15:11:56 +00:00
Vitor PamplonaandClaude Opus 5 397246b2ca feat(relays): show what each relay is doing, in the app's own words
Adds the purpose chips to the Connected Relays rows, and retires the invented
vocabulary the notification was using.

"Relay lists" and "Moderation" read as fuzzy because they were words I made up
that match nothing the user can navigate to. Every label now points at a string
the app already shows somewhere else — nav routes (Home, Discover, Messages,
Notifications, Chess), event-kind names (Reports, Profile, Follow List, Outbox
Relays, Drafts, Reactions) and feature names (Communities, Nests, Marmot Group,
Wallet). Twelve invented strings deleted; nine remain, only for jobs the app had
never had to name (Media, Hashtags, Topics, Conversation, Search, Quoted posts,
Add-ons, Relay info, Other). Those twelve also needed translating and now do not.

Checking those two labels found three real mis-buckets from the sweep's rule
ordering, all now fixed:

  FilterHomePostsByAuthors        RELAY_LISTS -> HOME_FEED     (nip65Follows in the
  FilterPictureAndVideoByAuthors  RELAY_LISTS -> MEDIA_FEED     path matched first)
  FilterDraftsAndReportsFromKey   MODERATION  -> ACCOUNT_DATA  (kinds = DraftKinds;
                                                                "report" in the filename)

So "Relay lists · 173 relays" was mostly the home and media feed fan-out, and
"Moderation" was counting drafts. RELAY_LISTS now covers only the outbox finder
and MODERATION only reports-about-you, which is what the words claim.

SubPurposeLabels is the single place a purpose becomes words, shared by both
surfaces so they cannot drift. Which jobs earn a notification line is now derived
from the taxonomy — the ACCOUNT and MESSAGES groups — rather than a hand-kept
list that happened to contain the same twelve.

The two surfaces stay deliberately different: the notification names only the
dozen jobs that survive backgrounding, while the relay screen someone opened on
purpose shows every job, feeds and current-screen work included.

Verified on device, notification re-read from the system:

    Notifications · 168 · Reports · 164 · Follow List · 134 · Relay Chats · 19
    Profile · 14 · Wallet · 11 · Communities · 8 · Messages · 6
    Marmot Group · 5 · Drafts · 3 · Browsing · 165

The chips themselves are compile-verified and read from the same
already-device-verified data, but the rendered row has NOT been seen — reaching
that screen needs drawer navigation that was not worth scripting. Worth a look
before merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:57:05 -04:00
Vitor PamplonaandClaude Opus 5 3192e14c5a feat(notifications): explain the relay count, but only when asked
Renames SubPurpose.isBackground to runsInBackground, and puts the per-job
breakdown behind the notification's expand affordance.

The collapsed line is byte-for-byte what it was — "Connected to 188 relays" —
because that is all most people want from an ongoing notification, and this one
sits in the shade permanently. The breakdown goes in a BigTextStyle, so it costs
nothing until someone deliberately expands it to ask why their phone is talking
to that many relays. It is skipped entirely when nothing is attributed yet, so an
empty section can never render.

Verified by reading the notification back out of the system rather than trusting
the code path (`dumpsys notification --noredact`):

    android.text     Connected to 188 relays          <- unchanged
    android.bigText  Connected to 188 relays
                     Relay lists · 173 relays
                     Notifications · 169 relays
                     Moderation · 159 relays
                     Your follows · 154 relays
                     Public chats · 21 relays
                     Profiles · 12 relays
                     Wallet · 12 relays
                     Communities · 9 relays
                     Direct messages · 6 relays
                     Encrypted groups · 6 relays
                     Your account · 3 relays
                     Browsing · 173 relays

Only the twelve jobs worth naming to a user get a line. Feeds and whatever screen
is open have no label and collapse into "Browsing" — they tear themselves down
once the app is backgrounded, which is exactly when this notification matters, so
itemising them would add noise precisely when nobody is interested.

The counts overlap on purpose and sum well past the relay count: a typical relay
carries four jobs at once, so there is no partition to show. The copy answers "how
many relays carry my DMs", not "how is the pool split" — which is why the earlier
sketched wording ("4 for notifications, 3 for DMs") was dropped; it implied a
partition that does not exist.

Strings are one reused plural plus thirteen labels, so the count agrees with its
noun in languages that decline it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:39:06 -04:00
Vitor PamplonaandClaude Opus 5 3eedcd9f89 refactor(relays): split SubPurpose into specific jobs, grouped and lifetime-aware
The first pass put 208 of 343 filters into SCREEN_CONTENT — a bucket covering the
whole Discover tab, every media feed, search, threads, profiles, badges, chess
and a mis-filed wallet screen. Useless for explaining anything. MODERATION ended
up with zero filters despite kind-1984 subscriptions being live, and
ENCRYPTED_GROUPS with zero because Marmot builds its filters in quartz and wraps
them later, so the sweep's `filter = Filter(` pattern never saw them.

SubPurpose is now 27 specific jobs on two axes:

- `group` rolls them up (ACCOUNT / MESSAGES / FEEDS / CURRENT_SCREEN) so a
  notification can say something short while a relay screen or bug report keeps
  the fine value. Adding a fine value stays cheap.
- `isBackground` marks what may outlive the foreground.

SCREEN_CONTENT is gone, replaced by DISCOVER_FEED, MEDIA_FEED, TAG_FEED,
COMMUNITY_FEED, TOPIC_FEED, THREAD, USER_PROFILE, SEARCH, ENGAGEMENT,
REFERENCED_EVENTS, ADD_ONS, GAMES and RELAY_INFO. CHATS splits into PUBLIC_CHATS,
COMMUNITY_CHATS, ENCRYPTED_GROUPS and LIVE_ROOMS. Marmot is tagged through
ExplainedFilter.of() at the wrap site, since quartz cannot see commons.

Every one of the 27 values is applied by at least one filter — checked, not
assumed. Nothing in a relay-bound path is left untagged.

Measured on device, cold start then HOME:

    foreground @45s   13 purposes   HOME_FEED 337 relays · NOTIFICATIONS 329 ·
                                    RELAY_LISTS 337 · MODERATION 326 · ENGAGEMENT 20
    background @90s    9 purposes   all isBackground=true

Both `isBackground = false` purposes in flight (HOME_FEED, ENGAGEMENT) were gone
after backgrounding, which is the invariant worth having: a CURRENT_SCREEN
purpose alive in the background is a leak, and that is now assertable.

The same run corrected the flag's documentation. RELAY_LISTS and FOLLOW_LISTS are
marked background-capable yet absent at @90s — because those loaders had nothing
to fetch, not because they were torn down. So `isBackground` is a ceiling, not a
promise: absence proves nothing, presence of a `false` one proves a bug. The KDoc
now says that instead of implying the stronger claim.

Worth a second look before this ships: WALLET holds 21 filters across 12 relays
while backgrounded — more relays than DIRECT_MESSAGES (5) or NOTIFICATIONS (8).
That may be correct for nutzap watching, or it may be more sockets than the
feature warrants. The tagging is what makes the question askable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:27:19 -04:00
Vitor PamplonaandClaude Opus 5 8c9475a3fb feat(relays): tag every relay-bound filter with its purpose
Completes the sweep: all 343 filters that reach a relay now carry a SubPurpose,
so `client.activeRequests(relay)` can answer "what is this relay doing for me?"
for every connection, not just the three assemblers wired as proof.

Scope was bigger than the "12 assemblers" first estimated — that count was files
calling newSubId(). The real target is filters wrapped in RelayBasedFilter, since
those are the ones that reach the wire: 202 files. The other ~400 `Filter(` sites
in the app query LocalCache and never leave the device, so they are deliberately
untouched.

Purposes assigned by subsystem: DIRECT_MESSAGES for the DM/giftwrap paths,
NOTIFICATIONS for the inbox filters, FOLLOW_LISTS / PROFILE_METADATA /
RELAY_LISTS for the account loaders, CHATS for public channels, relay groups,
Concord, nests and Marmot, HOME_FEED for the follow feed, WALLET for Cashu and
nutzaps, and SCREEN_CONTENT for everything opened on demand (thread, profile,
hashtag, video, polls, music, podcasts, git repos, workouts, chess).

Verified on device rather than by inspection — 369 relays attributed through
client.activeRequests() after a cold start, with no untagged relay-bound filter
left (`0` by grep). Observed distribution across relays:

    FOLLOW_LISTS 921 · HOME_FEED 895 · PROFILE_METADATA 881 · NOTIFICATIONS 690
    CHATS 82 · SCREEN_CONTENT 81 · WALLET 38 · DIRECT_MESSAGES 17 · RELAY_LISTS 8

which reads correctly: the outbox fan-out puts follows/feed/metadata/notifications
on almost every relay, while DMs and relay-list discovery stay on the few relays
that actually serve them, and the account's own relay carries everything.

That measurement also corrects an assumption behind the planned notification
copy: relays are NOT partitioned by job. A typical relay serves four purposes at
once, so "4 for notifications, 3 for DMs" would be wrong — per-purpose counts
overlap and sum to more than the relay count. The popup wording needs to reflect
that, which is why it is still not written here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:59:23 -04:00
Vitor PamplonaandClaude Opus 5 6691e519e8 feat(relays): record why each subscription exists, without telling relays
The always-on notification says "connected to N relays" and nothing more. That
count is emergent, not curated: NotificationRelayService owns no subscriptions,
and nothing closes a socket when it stops being useful, so a relay stays
connected exactly as long as some filter still references it. Neither a user nor
a developer can tell whether those N relays are carrying DMs or re-dialling a
stale outbox hint.

The client already tracks every in-flight REQ per relay with its filters
(INostrClient.activeRequests, which the Connected Relays screen reads). A filter
says *what* is matched — kinds, authors, tags — but not *who asked*, and subIds
are random. This adds the missing half.

`Filter` becomes `open`, with `copy()` open too, and commons gets
`ExplainedFilter` carrying a `SubPurpose`. The purpose rides on the filter, so it
arrives with the data the relay screens already read — no parallel registry to
keep in sync or leak on teardown.

**It never reaches a relay.** FilterSerializer is registered against Filter and
writes an explicit protocol field list, and Jackson applies a serializer
registered for a class to its subclasses — so an ExplainedFilter serializes to
byte-identical JSON. That is the point: telling relays what each REQ is *for*
would hand them a ready-made fingerprint of client intent and correlate
subscriptions that are deliberately kept apart.

`copy()` is overridden because filters are copied on the live path — assemblers
call copy(since = …) after every EOSE. Inheriting the base implementation would
downgrade to a plain Filter on the first window refresh, so the purpose would
survive the opening REQ and vanish seconds later.

Both invariants are pinned by tests, and both were mutation-checked rather than
assumed:
- removing the copy() override      -> `copy preserves the purpose` fails
- unregistering FilterSerializer    -> 3 tests fail, one reporting the literal
                                       leak: `purpose leaked to the wire: {…}`
A test also asserts FiltersChanged does not see the new field, so tagging a
filter cannot trigger a re-REQ storm across ~400 relays.

Wired three assemblers as proof (metadata, reactions, outbox finder) and surfaced
the derived set on BasicRelaySetupInfo.purposes for the Connected Relays screen.
Verified on device: 8 relays attributed RELAY_LISTS through
client.activeRequests() — purpleplag.es, user.kindpag.es, indexer.coracle.social,
directory.yabu.me and friends, which is semantically right — across 2,709 sent
REQs containing zero occurrences of "purpose" or any SubPurpose name.

The remaining assemblers are untagged, which is why `purposes` is documented as
"not yet attributed" rather than "idle". Notification-popup copy is deliberately
not built yet: it should describe the measured background grouping, not the
intended one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:48:01 -04:00
Vitor PamplonaandGitHub 1f4d9728be Merge pull request #3824 from vitorpamplona/claude/event-kind-0-empty-content-josbwa
Handle blank and malformed metadata gracefully
2026-07-29 22:17:41 -04:00
Claude 25c4516270 fix: parse kind 0 with empty or array-wrapped fields instead of erroring
Four kind-0 events collected from relays exposed two content-parsing
problems. Both `MetadataEvent.contactMetaData()` and `contactMetadataJson()`
are shared code, so Jackson (JVM/Android) and kotlinx (native) were equally
affected — verified against both mappers.

Empty content is a valid, empty profile — someone wiping their metadata —
not a parse failure. It logged "Content Parse Error" and returned null,
which made `LocalCache.consume` drop the event, so the stale profile stayed
in place forever even though a newer replaceable event had arrived. Blank
content now decodes to a blank `UserMetadata` (and an empty `JsonObject`).

A string field wrapped in a one-element array (`"nip05":["a@b.com"]`) has
only one possible reading, so `TolerantStringSerializer` now unwraps it
rather than silently dropping the user's NIP-05 verification. Empty,
multi-element, and non-primitive arrays stay ignored.

The other two events already behaved correctly and are pinned as
regressions: `"nip05":{}` with foreign client keys, and Ditto's ambiguous
`"birthday":"10-24"` — both drop just the offending field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGYcqfBKsP9SCvRdPzdkSx
2026-07-30 02:08:04 +00:00
Vitor PamplonaandGitHub d56d0cf725 Merge pull request #3819 from vitorpamplona/fix/log-defaults-and-concord-cross-epoch-floor
fix: quiet the default log, add a boot narrative, and stop pinning Concord entities across a Refounding
2026-07-29 21:56:52 -04:00
Vitor PamplonaandGitHub f7cf257365 Merge pull request #3821 from vitorpamplona/claude/notification-card-pattern-95omqu
Redesign channel invite cards as feed rows with metadata-only observers
2026-07-29 21:32:58 -04:00
Vitor PamplonaandClaude Opus 5 edd36b467c test(commons): deflake FeedMetadataCoordinatorTest
`loadMetadataBatched follows the same retry semantics` failed on the macOS
build-desktop runner (1431 tests in the module, 1 failed). The coordinator is
fine; the test was wall-clock coupled.

It fired call 1 with `timeoutMs = 200`, waited a flat `delay(350)`, then
asserted that call 2 re-subscribed. That leaves a 150ms budget for
`scope.launch` to be scheduled, subscribe, `awaitAll(200)`, unsubscribe and roll
the pubkeys out of `inFlightBatchedMetadata`. On a loaded runner the launch
itself can be queued past the margin, so the roll-back lands late, call 2
short-circuits by design, and the assertion fails on healthy code. Every test in
the file had the same shape.

"Has call 1 finished?" is a question about the job tree, not the clock, and the
test owns the scope — so it can just ask. `awaitCoordinatorIdle()` waits until
no child of the test scope is active; `waitUntil {}` polls the few preconditions
that aren't expressible that way (listener registered before EOSEs are fired).
Both carry a generous deadline and fail with a message rather than hanging. Safe
here because the coordinator launches nothing at construction and
`BatchEoseGate` closes and joins its consumer inside `awaitAll`, so the scope
does reach idle.

Also made the test fake thread-safe. `subscriptions` and `subscribeCalls` were a
plain map and list, written from the coordinator's `Dispatchers.Default`
coroutines (and `Dispatchers.IO` in the concurrent-EOSE test) and read from the
test thread: an unsynchronized `size` read can be stale, and `fireEose`
iterating `subscriptions.values` while a coordinator coroutine calls
`unsubscribe` can throw ConcurrentModificationException. Concurrency is the
thing under test, so the fake shouldn't be the weak link. Now
ConcurrentHashMap + synchronizedList + AtomicInteger, with a snapshot in
`fireEose`.

Verified deterministic rather than just green:
- 3/3 idle, 4/4 under 24 busy loops on 12 cores (the old shape needed the
  margin; the new one is load-independent).
- Still catches its regression: reintroducing the original mark-on-send bug
  (`eosedRelays > 0` -> `>= 0`) fails exactly this test. A deflaked test that no
  longer detects the fault would be worse than the flake.

This is a pre-existing flake (test dates from 5217035f94, 2026-07-07) unrelated
to the rest of this branch, which touches no commons code — fixed here because
it blocks the branch's CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:25:20 -04:00
Claude 5b357cd2fc perf: trim the channel-invite row's per-item cost
Three findings from auditing the previous commit.

observeChannel registered a ChannelFinder query per invite row that could
never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup
is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a
RelayGroupChannel contributes nothing and the registration only churned the
app-wide key set (an allKeys() Set copy per bundled invalidation) on every
mount and unmount. Collect the channel's metadata stateFlow directly instead —
same live name updates when the group's kind-39000 lands, none of the churn.

TimeAgo used the default Dotted style inside a row that already spaces its
children, so the variant's own leading " • " doubled the gap. DottedTight is
what the note header uses in exactly this position.

Key each row by channel id. The list is sorted newest-first, so an arriving
invite shifts every row below it; without a key Compose matches children by
call-site position and each shifted row recomposed against a different invite,
re-resolving the actor and reloading their avatar. Also remember the row
modifier rather than rebuilding the chain on every recomposition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
2026-07-30 01:16:29 +00:00
Vitor PamplonaandGitHub c47afe6427 Merge pull request #3823 from vitorpamplona/claude/buzz-relay-tor-reachability-7lrj4m
Fix Tor→clearnet fallback banner logic with live socket state
2026-07-29 21:10:22 -04:00
Vitor PamplonaandGitHub a2e199bd57 Merge pull request #3822 from vitorpamplona/claude/buzz-notification-relay-ebj12q
Extract RelayNameChip to shared component and add it to relay group message headers
2026-07-29 21:09:43 -04:00
Vitor PamplonaandClaude Opus 5 2d7ef2fd93 feat(logs): give the default log a boot narrative
The INFO default landed a readable log but an incomplete one: only the boot
census came from our own code, so the default read as "warnings + census" with
nothing between process start and the 20s mark. The first four seconds — the
expensive part — were silent.

Adds the missing spine, ten INFO lines a boot:

- `AmethystApp`: version, process and effective log level at start. The
  napplet-sandbox skip is promoted too — it is the one line that explains why
  `instance` is unset and `LocalCache` empty in that process, which otherwise
  reads as a broken app rather than a deliberately secret-free sandbox.
- `LocalPreferences`: how many accounts are saved (nearly every per-account
  subsystem multiplies by this), then each account's load with its elapsed ms.
  Decrypting one account's settings is among the most expensive things a cold
  start does and "which account was slow" is the first question when a boot
  drags. The six intermediate steps stay at DEBUG.
- `TorService`: every status transition, with time since bootstrap started. All
  status writes now go through one `setStatus` helper instead of nine scattered
  assignments, so a transition is logged exactly once; self-transitions are
  skipped because the reset paths reassign `Off` defensively. A stuck
  `Connecting`, an `Active` that took 40s and a silent drop to `Off` are three
  different bugs that used to look identical.
- `BootRelayDiagnostics`: adds a census at 5s. The pool is assembled and
  dialling well before 20s, and the early datapoint is what separates "slow to
  connect" from "connected fine, slow to serve" — on device it reads pool=21
  at 5s against pool=359 at 20s. Paid for by folding the `=====` banners down
  to DEBUG, so a census is 3 INFO lines instead of 5.

Reads on device as:

    I AmethystApp: Amethyst 1.13.1-DEBUG starting in main process (log level INFO)
    I TorService: Off -> Connecting
    I LocalPreferences: Found 4 saved account(s)
    I LocalPreferences: Loaded account npub1gcx… in 394ms
    I LocalPreferences: Loaded account npub142g… in 760ms
    I BootRelayDiag: census @5s pool=21 opened=17 served_events=14 …
    I TorService: Connecting -> Active after 9910ms
    I BootRelayDiag: census @20s pool=359 opened=158 served_events=78 …

Verified by tag on a cold start: +10 INFO lines from our code, no other change
to volume. (Raw totals moved more, but that run happened to autoplay video, so
15 extra INFO lines came from Android's media/codec stack — not from this.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:26:05 -04:00
Claude 21623cfd3f fix: debounce the Tor→clearnet offer on the socket and hide it when inert
Audit follow-ups on the previous commit's gating.

1. The `hasLoadedContent` gate over-suppressed. Cached channels in
   LocalCache are proof the socket answered *once*, not that it answers
   now — so a relay whose circuits died mid-session, leaving a full but
   frozen screen, would never get the offer. Replace it (and the
   one-shot grace timer) with a debounce on the connection itself: the
   countdown restarts on every drop and clears the moment the socket
   returns. That covers the cold-start case, the reconnect blip and the
   mid-session death with one signal, and no longer depends on what
   happens to be cached.

2. Don't offer what can't help. The action adds the relay to the
   kind-10089 Trusted list, which only moves it to clearnet while
   trusted relays are off Tor. Under the Small-Payloads and
   Full-Privacy presets (trustedRelaysViaTor = true) it changed no
   routing at all — the old `relay !in trustedRelays` condition merely
   hid the banner afterwards, so the dead button looked like it worked.
   Withhold the offer under those presets instead of silently flipping
   a global privacy setting from a per-relay banner.

Tests cover both gates plus the preset routing they read.
2026-07-29 22:24:41 +00:00
Claude 08b3cf266b fix: draw the "added you to a channel" prompt as a note row
The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.

Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.

The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.

Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
2026-07-29 22:23:13 +00:00
Claude f6e68f31d4 feat: name the host relay on Buzz notification cards
A Buzz/NIP-29 group id is only unique within its host relay, so a message
notification titled just "#general" doesn't say which #general it came from.
The Notifications card already draws a RelayGroupChannelHeader naming the
channel; add the relay next to it, mirroring what a Concord message gets on
the same cards (ConcordCommunityPill naming its parent community) and what the
Messages row already does for these groups.

Extracts the relay chip out of ChatroomHeaderCompose into a shared
RelayNameChip so the Messages row and the Notifications feed render the same
chip, the way ConcordCommunityPill is already shared between them. The chip
taps through to the relay's channel list; the name/avatar still open the room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DsoLSb42C3CdjMceJW2Pq
2026-07-29 22:22:11 +00:00
Vitor PamplonaandGitHub 27c863925a Merge pull request #3820 from dmnyc/fix/nip73-scope-prefetch
perf(nip73): prefetch external-content preview URLs
2026-07-29 18:13:30 -04:00
The Daniel c4c8649fcc perf(nip73): prefetch external-content preview URLs
Closes #3817.

collectWarmTargets() harvested link URLs from note content but not from
NIP-73 `I`-tag scopes, so the external-content preview cards popped in
mid-scroll while every other link preview was warmed ahead of view.

Gated on `is CommentEvent` so only kind 1111 walks the tags, keeping the
other kinds off that path. Reuses scope() -- the same accessor the
renderer uses -- so a URL is only warmed when it will actually be shown.
Inherits the existing showUrlPreview() data-saver gate already applied to
targets.links.
2026-07-29 18:03:36 -04:00
Vitor PamplonaandGitHub 112fbb8379 Merge pull request #3818 from vitorpamplona/claude/local-blossom-main-thread-anr-sfcfv9
Confine Blossom cache probe and resolver to IO dispatcher
2026-07-29 18:02:24 -04:00
Vitor PamplonaandClaude Opus 5 6fbcb880dd fix(concord): stop pinning entities whose chain crosses a Refounding
An entity edited after a CORD-06 Refounding was frozen at its pre-Refounding
version and its newer state silently discarded on every refold. Observed on
device: entity e83ee182 of a real community held at v1 across 21 consecutive
refolds while the current epoch offered v2 citing v1's own hash.

`EditionFold.foldEntity` anchored the walk by requiring the offered set to
*contain* the floor edition. But a Refounding re-wraps ONE edition per entity
(CORD-06 §3, "the last Control Plane state is simply rewrapped"), so an entity
edited afterwards has only its successor on the new epoch while the floor
edition stays behind on the old one. `prev == floor.hash` is a stronger proof
the chain connects than mere presence, and it was being ignored. Worse, the
gap made `admissible` — the pre-filter every derived fold shares — keep only
`version < floor.version`, so the newer edition was dropped every refold: a
role edit, channel rename or banlist entry reverting itself for that user.

Checked against Armada (semantics only, AGPLv3) and the canonical spec before
changing consensus behaviour. Armada selects an arm per entity, and Amethyst
was missing both halves:

1. Its chain-walk arm has three anchor branches to our one; the missing
   `versions[0] === floor + 1n -> bytesEq(lo.prevHash, floorHash)` is now
   implemented.
2. Once an entity has been re-wrapped into the epoch being folded it does not
   chain-walk at all — it anchors on version alone (`bootstrapHead`), because
   behind a compaction dangling `prev`s are normal and, since seal signatures
   survive re-wrap, any group-key holder can re-serve a genuine old edition
   under the current group. A re-wrap cannot raise the version inside the
   signed seal, so version is what bounds that. This is the half that fixes
   the observed pin; branch 1 alone would still refuse a floor-v1 entity whose
   only served edition is v3.

Implemented as an optional `snapshot` (the rumor ids of the epoch being
folded), defaulting to null = pure chain walk for every other caller.
`ConcordCommunityState.fold`/`authorizedHeads` capture it from their own
editions argument before `admissible` re-seats older-epoch heads — Amethyst
folds exactly one epoch per call, so the argument is the snapshot. `admissible`
and `candidates` now ask `foldEntity` whether it gapped rather than
re-deriving the test, so the three sites cannot drift apart again.

`LOG_GAP` also dedups on (entity, floor, offered): the same refusal was
re-reported on every refold, 22 byte-identical warnings a boot, which read as
22 attacks rather than one unchanged state. Deduping is what made the two
genuinely distinct refusals visible and led here. Fails open above 4096
distinct refusals — a flood is when the warnings matter most.

Anti-rollback is unchanged: all 12 pre-existing floor tests still pass, and
three new negative tests cover the fork, the below-floor re-serve, and an
entity absent from the snapshot. Device gaps for the pinned entity: 21 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:01:14 -04:00
Vitor PamplonaandClaude Opus 5 60e3df72b2 fix(logs): make the default log a boot narrative, not a firehose
A cold start emitted 6,198 app lines in 80s (~77/sec) on a real device.
Six tags produced 83% of it, and the lines that are actually actionable —
relay protocol refusals like `auth-required`, `rate-limited`,
`unsupported: too many filters` — were buried among them.

`Log.minLevel` was `DEBUG` in debug and `ERROR` in release: two settings,
both wrong. Release dropped all 460 `Log.w` call sites, so the field never
saw a single relay refusal. Debug kept everything.

Now debug defaults to INFO and release to WARN, with `Amethyst.VERBOSE_LOGS`
to restore the firehose. 333 files already route through `quartz.utils.Log`
(only 13 use raw `android.util.Log`), so `minLevel` is a real global gate —
which is why three of the four loudest buckets needed no code change at all:
relay socket lifecycle (1,659 lines), `RelaySpeedLogger` (1,308) and Arti's
per-stream SOCKS errors (753) were already `Log.d`.

The rest:
- `RelayLogger.onCannotConnect` E -> D. Under the outbox model a boot dials
  ~400 relays and most fail identically; one line per dead socket is not
  actionable, and its multi-line TLS certificate dumps were 3-4 logcat lines
  each. This one mattered most: at `Log.e` it survived every level.
- `BootRelayDiagnostics` rollup -> INFO, per-relay WASTE/SERVE tables -> DEBUG.
  The census line carries the aggregate the 655 per-socket errors spelled out.
- `Duplicated/SPAM` -> DEBUG: a detection is the filter working, and it is
  already reported via `relayStats.newSpam()` and `flowSpam` to the UI.
- `MarmotDbg` "not a member of group" -> DEBUG: relays serve kind:445 for
  every group they carry, so this is the steady state. It was burying the
  real warning next to it ("Generation N already consumed").

Measured on emulator (4 accounts, Tor on): 6,198 -> 433 lines. Flipping
VERBOSE_LOGS gives 5,574 back, so nothing was deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:01:14 -04:00
Claude 14046fd0ce fix: gate the Buzz Tor→clearnet banner on real relay reachability
The "Can't reach this relay over Tor" card on a community's screen was
driven by a bare 6s timer: `connectTimedOut` flipped true after the delay
and never flipped back, and the condition never consulted the relay at
all. On a Buzz relay the banner is an unconditional item in the sectioned
list (the empty-list guard only covers the vanilla NIP-29 branch), so it
appeared six seconds after opening any community and stayed up while the
relay was connected and delivering messages.

The Tor half was wrong too: `torType != OFF` says Tor is enabled somewhere,
not that this relay is routed through it — onion, localhost and the
per-role presets (trusted / DM / new) each decide independently.

Gate the offer on live signals instead, extracted into a pure predicate:

- routed over Tor  — TorRelayEvaluation.useTor, the same predicate the
  relay pool dials with
- Tor is up        — TorManager.status is Active, so a bootstrapping Tor
  isn't misreported as the relay blocking exits
- not connected    — client.connectedRelaysFlow(); a relay that answers is
  reachable by definition
- nothing loaded   — no channels/DMs on screen, so a reconnect blip can't
  flash the banner over a live list
- grace elapsed    — unchanged, so a slow first connect isn't nagged

Adds unit tests for each gate, including the reported regression
(connected and delivering -> banner stays hidden).
2026-07-29 21:43:08 +00:00
Claude 91bd2b44ee fix: re-probe the local Blossom cache when the toggle flips
Flipping either local-cache setting called probe.invalidate(), which only
clears the TTL timestamp — it never re-runs the probe. Nothing else does
either: isAvailable() is reached only from the one-shot startup warm and
from inside findServers().

That leaves the feature dormant when enabled mid-session. The feed image
path gates on `available.value` (AccountViewModel.useLocalBlossomBridge
and shouldBridgeBlossomCache), so while the probe reads false no request
ever routes through the resolver, and the resolver is the only thing that
would have refreshed the probe. The settings "detected" chip reads the
same flow, so it also stays stale.

Re-probe right after invalidating so enabling the toggle activates the
feature — and reports its true state — in the same session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
2026-07-29 21:34:45 +00:00
Vitor PamplonaandGitHub 09902fe470 Merge pull request #3816 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 16:17:43 -04:00
Vitor PamplonaandGitHub 14b615b10d Merge pull request #3810 from dmnyc/feat/nip73-comment-previews
feat(nip73): show rich previews for external-content comments
2026-07-29 16:17:30 -04:00
vitorpamplonaandgithub-actions[bot] 8678c88db6 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 20:15:13 +00:00
Vitor Pamplona 6fac964d16 Improving the shape of the mention icon 2026-07-29 16:11:40 -04:00
Vitor PamplonaandGitHub 96a896f78e Merge pull request #3814 from vitorpamplona/claude/corcord-buzz-height-difference-8fp108
Remove facepile from channel rows, simplify layout
2026-07-29 15:41:17 -04:00
Vitor PamplonaandGitHub 1c24ba9cf6 Merge pull request #3815 from vitorpamplona/claude/push-notification-icons-branding-dy4egx
Add Nostr badge watermark to notification icons
2026-07-29 15:40:58 -04:00
Claude 9e4aedee05 fix: enlarge the media Amethyst peak another 10%
Gem height 9.68 -> 10.65.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:39:11 +00:00
Claude eae0ca814d fix: raise the media Amethyst peak 2%
cy 15.62 -> 15.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:36:43 +00:00
Claude ec60834046 fix: enlarge the media Amethyst peak 10%
Gem height 8.8 -> 9.68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:31:48 +00:00
Claude 2684d2d4a8 fix: nudge the media Amethyst peak down 1% and right 2%
cx 15.28 -> 15.76, cy 15.38 -> 15.62.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:29:55 +00:00
Claude fd6696da82 fix: lower the media Amethyst peak another 1%
cy 15.14 -> 15.38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:28:40 +00:00
Claude 00c46bcc92 fix: nudge the media Amethyst peak slightly down and right
cx 14.8 -> 15.28, cy 14.9 -> 15.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:26:41 +00:00
Claude 26ad072993 fix: raise the media Amethyst peak 5%
Move the gem back up (cy 16.1 -> 14.9) for a better balance with the mountain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:21:32 +00:00
Claude 52643fb48f fix: lower the Amethyst peak in the media icon ~10%
Move the gem down (cy 13.7 -> 16.1) so it sits lower against the mountain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:20:22 +00:00
Claude 202163f4b9 perf: keep Local Blossom resolver/probe off the main thread
BlossomServerResolver.findServers and LocalBlossomCacheProbe.probe are
suspend functions that don't confine to Dispatchers.IO. They're reached
from Compose produceState/LaunchedEffect (RichTextViewer,
MarmotGroupIconDisplay, ZoomableContentView), which run on the main
dispatcher, so the pre-suspension work — BlossomUri regex parsing,
LruCache lookups, the probe's OkHttpClient build on TTL expiry, and the
server-list flow setup — executed on the UI thread on every uncached
resolution during feed scroll.

Wrap findServersInner and the probe's client-build + HEAD in
withContext(Dispatchers.IO) so that work stays off the main thread.
Behaviour is unchanged; callers already on IO (Coil BlossomFetcher,
PlaybackService) are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
2026-07-29 19:19:00 +00:00
Claude 097c3e874d feat: media icon uses the Amethyst crystal as a mountain peak
Drop the second mountain and the sun; place the Amethyst gem on the ground
as the second peak beside a single triangular mountain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:15:03 +00:00
The Daniel adc36017d1 fix(nip73): honor data-saver gate and fix preview icon touch target
Review feedback from PR #3810:

- Gate both new preview call sites behind settings.showUrlPreview(), so a
  user on WIFI_ONLY over cellular or NEVER no longer triggers an outbound
  OpenGraph request and image download per external-scoped comment. Falls
  back to the plain link chip / URL header.
- Wrap the open-in-browser icon in an IconButton so it gets a real touch
  target instead of a 14dp one nested inside the card's own clickable,
  where a near-miss silently navigated instead.
- Only show that icon when onCardClick is set; without it the icon and the
  card tap did the same thing, so existing note-body link cards stay as-is.
- staticCompositionLocalOf for LocalCurrentExternalScope (constant per
  screen, avoids read tracking per feed row).
- Inset the loading/error URL header to match the card, and use the
  hoisted Size14Modifier.
2026-07-29 15:12:41 -04:00
Claude 65073c351c fix: nudge the mention ostrich 3% back left
Shift the ostrich centre from x=13.2 to x=12.48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:11:08 +00:00
Claude 34633624cc fix: nudge the mention ostrich ~5% right
Shift the ostrich centre from x=12 to x=13.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:09:21 +00:00
Claude 9f367269e6 fix: tuck the mention bridge ends behind the ostrich
Pull the bridge's inner endpoints toward the logo centre so their raw ends
hide under the ostrich (drawn on top) instead of showing as stubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:05:55 +00:00
Claude 9f77245860 fix: enlarge the mention ostrich ~10%
Grows the Amethyst ostrich in the @ from 9.45 to 10.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:02:05 +00:00
Claude b185f58ada fix: keep the @ bridge and enlarge the ostrich 5%
Drop only the inner "a" circle from the @ outline, keeping the bridge
segments that connect the ring to the "a", and size the ostrich up 5%
(9.0 -> 9.45).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:59:43 +00:00
Claude f79741e611 fix: remove the @'s inner ring so the ostrich stands alone
The leftover inner "a" ring sat in front of the ostrich, making the logo
look like it was behind the circle. Drop the inner-a segments from the @
outline, leaving just the outer ring + tail, and size the ostrich so it
reads clearly as the "a".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:55:25 +00:00
Claude ff40d49b78 fix: shrink the mention ostrich so it reads as the bird
Smaller centered Amethyst gem inside the @ so the ostrich is distinct
rather than a blob filling the a-space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:49:02 +00:00
Claude 439bc5d5f9 feat: mention = Material @ outline with the gem as the "a"
Reuse the original Material @ shape (keeping its native tail and opening),
drop its inner "a" circle, and place the Amethyst gem there instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:33:47 +00:00
Claude bde562b4a6 chore(concord): remove now-unused facepile helpers
Delete ConcordAuthorFacepile and the ConcordChannel/RelayGroupChannel
recentAuthorHexes extensions, orphaned after the channel rows dropped the
recent-posters facepile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017x22okBh6HbiCrJN3zf5xn
2026-07-29 18:13:06 +00:00
Claude 1793ee5ffe Merge remote-tracking branch 'origin/main' into claude/push-notification-icons-branding-dy4egx 2026-07-29 18:06:34 +00:00
Vitor PamplonaandGitHub 07267e254b Merge pull request #3813 from vitorpamplona/claude/notecompose-media-rendering-f4k7nh
Consolidate NIP-71 video event types under shared interface
2026-07-29 14:04:45 -04:00
Claude 16030599d9 fix(concord): align top-bar height and simplify channel rows
Route the Concord community screen and hub through ShorterTopAppBar (50dp)
instead of the raw Material3 TopAppBar (64dp default), so the top bar — and
its 3-dot overflow menu — sits at the same height as the Buzz/relay-group
community screen, which already uses the shorter bar.

Also drop the recent-posters facepile from the Concord and Buzz channel
rows, and move the last-message time up to the first line (next to the
channel name), leaving the unread-message badge on the second line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017x22okBh6HbiCrJN3zf5xn
2026-07-29 17:18:29 +00:00
Claude ee8623bd20 refactor: dispatch all NIP-71 video kinds via the VideoEvent interface in NoteCompose
RenderNoteRow matched the four concrete video subtypes (VideoNormalEvent 21,
VideoShortEvent 22, VideoHorizontalEvent 34235, VideoVerticalEvent 34236) with
four identical branches. Collapse them into a single `is VideoEvent` branch,
matching how ThreadFeedView's NoteMaster already dispatches, so any future
VideoEvent subtype renders through VideoDisplay automatically instead of
falling through to the text fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCKP4tD2seoDpr3ZcdPJNx
2026-07-29 17:01:53 +00:00
David KasparandGitHub 7e6170390d Merge pull request #3808 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 18:52:04 +02:00
Claude de1c4905cc Revert "fix: make mention connector a solid tapering wedge"
Back to the curved thin connecting stroke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:44:17 +00:00
vitorpamplonaandgithub-actions[bot] 0279938e05 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 16:41:58 +00:00
Claude 536d3cf1a5 fix: make mention connector a solid tapering wedge
Fill the connector as a solid region: thick at the gem's right side,
tapering to the gem's bottom tip and merging into the ring's upper opening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:38:52 +00:00
Vitor PamplonaandGitHub 979ce7a96f Merge pull request #3812 from vitorpamplona/claude/buzz-channel-delete-persist-e3lex3
NIP-29: Channel deletion, archival, and Buzz community UI
2026-07-29 12:38:42 -04:00
Vitor PamplonaandGitHub 7cda707380 Merge pull request #3811 from vitorpamplona/claude/draft-note-expansion-threadview-u2u32l
Make draft posts in thread view open edit screen on tap
2026-07-29 12:36:44 -04:00
Claude b58543fb2d fix: curve the mention connecting stroke
Replace the flat bar with a curved stroke that sweeps down from the gem and
up into the ring's upper opening, reading as a natural @.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:33:30 +00:00
Claude 707cffec6d fix: connect mention tail to the upper end of the ring opening
The bridge now joins the gem to the ring's upper-right end (~3 o'clock),
leaving the lower-right as the ring's free opening — the correct @ side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:29:15 +00:00
Claude d44264a1ac feat(buzz): fix channel/forum type on the create screen from the caller
The create screen no longer shows a "Forum channel" toggle — the type is decided
by the community's per-section "+" (Channels vs Forums) and a Buzz channel's
channel_type isn't editable anyway (the relay's 9002 has no such key). The screen
loads with the fixed isForum parameter and creates the right type; its top-bar
title reads "New forum" when isForum, "New channel" otherwise (on Buzz).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 16:22:34 +00:00
Claude 04243d8dfa fix: open edit-draft screen when tapping a draft in ThreadView
Tapping a note in the thread view toggles the collapse/expand state of
its children. For the user's own drafts, tap now opens the edit-draft
screen (via routeEditDraftTo) so the post can be resumed, matching the
behavior everywhere else a draft is tapped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQCNuxdYkfouNN3ujxm3mR
2026-07-29 16:19:17 +00:00
Claude 60161e3fae fix: mention tail connects the gem to the ring
Bridge the tail from the gem's bottom down into the ring's lower opening,
like the inner "a" stem joining the circle in an @.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:14:33 +00:00
Claude ca61eb107d fix(buzz): drop deleted channels that leaked as bare UUID rows
A channel deleted elsewhere (or before this device saw its metadata) keeps its
kind-44100 membership — the relay never retracts it — but the relay soft-deletes
its 39000, and it does NOT serve a channel_deleted 40099 for an already-deleted
channel, so the discovery fetch can't catch it. Such a channel arrived with no
metadata and was shown optimistically: a bare UUID row with "No messages yet".

The reliable signal is that very metadata absence. BuzzRelayImportViewModel.discover
now drops channels the relay serves no 39000 for — but only when the fetch clearly
worked (some channel returned a 39000); if none did, the read failed (auth/
connectivity) and the whole set is kept rather than blanking the community. A
genuinely new channel whose 39000 is merely slow returns on the next bind once its
metadata is cached, so the prune is live (not persisted) and self-correcting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 16:09:11 +00:00
Claude 9d2c5e0cee fix(buzz): show the channel/forum "+" on any community, not gated on an unloaded roster
The "+" was gated on BuzzCommunityMembership (kind-13534), but that roster is only
fetched by the Members screen — nothing on the channel-list screen subscribes to
it, so isMember read false and the "+" hid even from admins. Match the workspace
overflow menu's approach instead: offer create on any Buzz community and let the
relay reject a non-member's kind-9007 (its own doc: "any member sees them, the
relay only serves the owner/admin ones").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 16:07:07 +00:00
Claude e6bce31249 feat: add connected @ tail to the mention ring
The tail now flows out of the ring's upper opening end and hooks to the
right, connected to the ring rather than floating beside it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:05:44 +00:00
Claude 38e5c848a4 feat: add @ opening to the mention ring
Cut a clean gap in the lower-right of the circle for the @ opening, with no
separate tail curve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:54:25 +00:00
The Daniel ac426531f3 feat(nip73): show rich previews for external-content comments
Kind 1111 comments scoped to a NIP-73 external identifier (e.g. a web
article) rendered as a bare URL with no context. Reuse the existing
OpenGraph preview pipeline so feed rows, expanded threads, the
reply-compose banner, and the URL thread screen all show a rich
image/title/description card instead, and give the URL thread screen
a proper scrolling header instead of a bare title.
2026-07-29 11:50:17 -04:00
Claude 967d51b6f9 fix: mention is a gem inside a clean complete circle
Drop the @ tail attempts, which read as a drooping curve. The mention is
now just the centered Amethyst gem with a clean full circle around it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:46:21 +00:00
Claude 21329b6dab fix: reshape mention @ tail to flick out to the right
The tail read as a downward-drooping comma; reshape it to flick outward to
the right like a proper @ tail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:44:31 +00:00
Claude 240f4e67a6 fix: thicken the mention circle stroke
Widens the @ ring stroke (1.4 -> 2.1) for a bolder circle around the gem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:38:25 +00:00
Claude a41db22030 feat: redo mention as a clean @, bump chess gem 2%
- Mention: gem centered like the "a" with a near-complete circle around it,
  opened at the lower-right with a small tail — a proper @ shape, ring no
  longer touching the gem.
- Chess: enlarge the gem 2%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:36:19 +00:00
Claude 417147cfe7 fix: move chess gem to the right side of the box
Positions the Amethyst gem on the right, blending into the knight's base
where they meet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:33:28 +00:00
Claude afaf5f582b feat: balance code icon; blend chess gem into the knight
- Code: widen the < > chevrons (0.62 -> 0.85) and shrink the gem (12 -> 10)
  for a more even balance.
- Chess: enlarge the Amethyst gem so it rides over and blends into the
  bottom of the knight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:27:38 +00:00
Claude c13d2840b9 fix: match code gem height to the < > height
Grows the centered Amethyst gem so its height equals the bracket height
(y6..18); the slimmed chevrons leave room for it to sit between them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:21:57 +00:00
Claude bdc21fee1e fix: slimmer code brackets, bigger gem
Thins the < and > chevrons horizontally about their own tips (height kept,
still at the far edges) and enlarges the centered Amethyst gem (7.6 -> 8.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:20:26 +00:00
Claude 22383e7a1c fix: spread code < > to the far left/right edges
Splits the brackets and pushes < to the left edge and > to the right edge,
opening a clean centered gap for the Amethyst gem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:17:19 +00:00
Claude 6ba53918c4 feat: @-style mention, rebalance zap, narrow code brackets
- Mention: thinner ring whose open lower-left end flows into the gem's
  bottom-left facet via a tapered stroke, echoing how an @ is drawn.
- Zap: enlarge the bolt and shrink the top-right gem.
- Code: squeeze the < > brackets narrower (width only, height kept) so they
  hug the centered gem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:15:36 +00:00
Vitor PamplonaandGitHub 2496a7fb3d Merge pull request #3809 from vitorpamplona/claude/flaky-quic-stream-drain-oge3ze
Fix flaky PeerUniStreamDrainTest by confining drainer to producer's event loop
2026-07-29 11:09:18 -04:00
Claude c422a9d022 feat: rework zap layout — bolt left, gem in the top-right corner
Replaces the centered gem-knockout bolt with the bolt moved to the left
and the Amethyst gem as a separate mark in the top-right corner, mirroring
the reply layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:09:14 +00:00
Claude 3510296673 fix: make the reply arrow and gem larger still
Grows the reply arrow (0.78 -> 0.9) and the top-right gem (0.045 -> 0.052)
to fill more of the canvas, keeping the diagonal layout without collision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:06:49 +00:00
Claude f723310dc7 feat(buzz): archive/unarchive channels + fix visibility edits on Buzz
Two channel-management gaps vs the Buzz interface (both ride kind-9002 tags; no
new protocol), verified against block/buzz:

Archive/Unarchive — the reversible hide-from-the-sidebar the Buzz client has,
distinct from delete. EditMetadataEvent gains an `archived` tag; Account/
AccountViewModel expose archiveRelayGroup; the channel and forum top bars offer
Archive/Unarchive to admins (no confirm — it's reversible). The relay stamps
`["archived","true"]` on the 39000, so GroupMetadataEvent.isArchived() /
RelayGroupChannel.isArchived() read it directly; the community list drops archived
channels out of Channels/Forums into a collapsed "Archived" tail from which they
can be reopened and unarchived.

Visibility-on-edit — a Buzz relay reads its own `visibility` (open/private) tag,
not the NIP-29 `private` status flag, so the edit screen's private toggle was a
silent no-op on Buzz. editRelayGroupMetadata now sends the `visibility` tag on
Buzz relays (status flag still sent for vanilla NIP-29).

Not gaps (checked): topic/purpose/TTL are in the relay's system-message
vocabulary but not extracted on 9002/9007, so there's nothing to mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 15:05:59 +00:00
Vitor PamplonaandGitHub 662bd87495 Merge pull request #3807 from vitorpamplona/claude/swipe-reply-sensitivity-y0y0v1
Refactor chat bubble swipe gesture to use lower-level pointer APIs
2026-07-29 11:05:32 -04:00
Claude 6e2a8f00f4 fix: enlarge reply arrow and gem
Bumps the reply arrow (0.62 -> 0.78) and the top-right Amethyst gem
(0.035 -> 0.045) so both read larger while keeping the bottom-left /
top-right diagonal layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:02:42 +00:00
Claude 21c44cfc36 test(quic): de-flake PeerUniStreamDrainTest slow-consumer overflow
drainPeerInitiatedUniStreamsIntoBlackHole_keeps_connection_alive passed
in isolation but flaked under parallel load with the audit-4 #3
"slow consumer overflowed incoming channel" teardown.

Root cause: the test ran the black-hole drainer on the shared
Dispatchers.Default pool. The drainer only has to keep the 64-deep
per-stream incomingChannel drained, but when the rest of the JVM test
suite saturates every Default worker thread the drainer coroutine can be
denied a scheduling slot long enough for the synchronous feed loop to
push more than 64 chunks ahead of it, tripping the slow-consumer
teardown. The fixed-delay yields (delay(1) every 16 chunks) do not help
when all pool threads are pinned.

Fix: run the drainer on the runBlocking coroutine's own single-threaded
event loop (CoroutineScope(coroutineContext + SupervisorJob())) and pace
the feed loop with cooperative yield() instead of wall-clock delays. Each
yield deterministically runs the drainer's queued channel-receive
continuation before the producer resumes, so the buffer never approaches
its bound regardless of machine load. The drain contract under test
(parser trySend -> incomingChannel -> collect -> discard) is exercised
identically; only the scheduling is pinned.

Verified with a temporary reproduction that pins every Default worker
thread: the old Default-scheduled drainer overflows to CLOSED while the
confined drainer stays CONNECTED under the same saturation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGZt6D298kGv2XgAi3Uuaj
2026-07-29 15:02:16 +00:00
David KasparandGitHub cb648011d3 Merge pull request #3806 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 16:59:01 +02:00
Claude 22503b8fe3 fix: tidy article icon lines and drop-cap alignment
Four evenly-spaced text lines (two short up top beside the gem drop-cap,
two full-width at the bottom) with equal spacing, and the drop-cap gem's
top aligned to the top line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:58:44 +00:00
Claude 0284c8c86a fix: keep swipe-to-reply from stranding the chat bubble off-screen
Two ways the bubble could get stuck shifted sideways (dragOffset frozen at a
non-zero value, reply icon left visible), both fixed:

1. Cancelling the settle-back animation on awaitFirstDown — which fires for
   every touch, including one that turns out to be a vertical scroll — while
   settleBack() only ran on the horizontal-drag path. A scroll started during
   an in-flight settle froze dragOffset. Now the settle is cancelled only once
   a horizontal drag is actually committed, so every cancel is paired with a
   settleBack().

2. Keying pointerInput on the onSwipeReply lambda. The caller allocates a fresh
   lambda every recomposition (it captures the note), so the detector was torn
   down and restarted on every row update. A restart mid-drag cancelled
   horizontalDrag before settleBack() ran, stranding the bubble; idle restarts
   also churned the gesture coroutine across all visible bubbles. Now keyed on
   the stable isLoggedInUser, with the callback read through rememberUpdatedState
   and the drag wrapped in try/finally so an interrupted drag always settles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
2026-07-29 14:58:08 +00:00
davotoulaandgithub-actions[bot] d504e43924 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 14:58:05 +00:00
Claude 422bf10337 feat: rework reply, zap layout and grow reaction gem
- Reply: keep the arrow at its size but move it to the bottom-left and put
  the Amethyst gem in the top-right corner.
- Zap: scale the bolt up to fill the canvas and knock the gem out of its
  center (inverted), so the logo reads in front of the bolt.
- Reaction: grow the heart's knocked-out gem ~10% and nudge it toward the
  bottom of the heart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:57:05 +00:00
davotoula f353389892 update cs,sv,de,pt 2026-07-29 16:54:25 +02:00
Claude 97ff971542 feat(buzz): move per-row channel actions into the opened screen's top bar
The community list rows (channels, forums, DMs) each carried a 3-dot overflow —
Pin/Unpin and Add/Remove-from-Messages. Those are gone; each row is now a clean
tap-to-open target and its actions live in the top-bar overflow of the screen it
opens:

- Channels & DMs open RelayGroupChatScreen (RelayGroupTopBar): add Pin/Unpin
  (Buzz, non-DM) alongside the existing Messages toggle. A DM has no kind-10009
  entry, so its Messages toggle drives the DM-specific hide/unhide (kind-41012 /
  re-open) read from the per-viewer 30622 snapshot; the DM overflow is always
  shown so that action stays reachable.
- Forums open RelayGroupThreadsScreen: give its top bar an overflow with Pin/Unpin
  and Add/Remove-from-Messages.

Shared BuzzPinDropdownItem / RelayGroupMessagesDropdownItem keep the two bars
consistent. AccountViewModel gains hideBuzzDm/unhideBuzzDm wrappers. The
community-list top-bar overflow (Add people, Invite, Agent Console, Add all) is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 14:36:42 +00:00
Claude ce25273898 perf: animate swipe-to-reply icon in the layer phase, not composition
The reply icon's fade/scale read dragOffset in composition, and because Box is
an inline composable that read invalidated the whole ChatBubbleLayout on every
drag and settle frame. Gate the icon on a derivedStateOf boolean that flips only
at the 0<->non-0 edges (icon added/removed twice per gesture) and move the
progress math into the icon's graphicsLayer block so it's read in the layer
phase. The bubble translation was already deferred; now the entire swipe and
settle animate with zero recomposition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
2026-07-29 14:33:47 +00:00
Vitor PamplonaandGitHub 3cfa2e0efb Merge pull request #3805 from vitorpamplona/chore/bump-winget-manifest-v1.13.1
chore: sync winget manifests to v1.13.1
2026-07-29 10:33:20 -04:00
vitorpamplonaandgithub-actions[bot] bf73e0934a chore: sync winget manifests to v1.13.1 2026-07-29 14:31:13 +00:00
Vitor Pamplona 8a183d1cbd refactor(ci): move the Winget bump out of CI onto a maintainer machine
Mirrors the Homebrew cask change. The old design stored a classic `public_repo`
PAT as WINGET_TOKEN and handed it to the third-party
vedantmgoyal9/winget-releaser action: a token with write access to every public
repo the owning account can reach, given to code we do not control, in a place
any push-access collaborator could read it from (a pushed branch containing a
workflow runs with repo secrets).

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

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

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

Drops the last package-manager PAT from the secret inventory.
2026-07-29 10:30:21 -04:00
Claude 34fa103dbd feat(buzz): gate the channel/forum "+" to community members
Only a Buzz community member may create a channel (the relay makes the creator
its owner; a non-member's kind-9007 is rejected), so the section-label "+" now
shows only when the viewer is in the community's NIP-43 roster. Reactive: the
relay-signed roster snapshot (kind 13534, republished on every membership change)
can arrive after first composition, so BuzzCommunityMembership is re-read whenever
one lands. The section labels themselves still render for everyone; only the "+"
is gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 14:26:23 +00:00
Claude b4798331e8 fix: shrink badge gem ~20%, grow heart gem ~10%
Rebalances the two knockout icons: the badge gem drops from 11.6 to 9.3
(more disc margin around the mark) and the reaction heart gem grows from
8.2 to 9.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:23:12 +00:00
Claude 0703797bdb feat: bigger repost arrows; invert + maximize gem in badge
- Repost: scale the repeat-arrow glyph up ~15% so it fills the canvas like
  the original, with the centered gem enlarged to match.
- Badge: replace the medal ring + inner-circle-with-logo with a solid disc
  that has the Amethyst gem knocked straight out of it (inverted), sized to
  fill the disc. Ribbon tails kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:20:16 +00:00
Vitor Pamplona db529444c4 refactor(ci): move the Homebrew cask bump out of CI onto a maintainer machine
`brew bump-cask-pr` forks homebrew-cask into the token owner's account, which
requires a CLASSIC PAT with the `repo` scope. That scope cannot be narrowed: it
grants write to every repository the owning account can reach, including this
one. As an Actions secret it would be usable by anyone with push access here,
because a pushed branch containing a workflow runs with repo secrets — a strict
escalation for a channel that ships one DMG a month.

Split the work so the token never becomes a CI secret:

- CI (bump-homebrew.yml, now "Sync Homebrew Cask Reference") does the
  error-prone bookkeeping with GITHUB_TOKEN only: downloads the DMG, asserts it
  is notarized + stapled, computes the sha256, and opens an in-repo PR syncing
  desktopApp/packaging/homebrew/amethyst-nostr.rb. This makes it a third sibling
  of the amy/geode formula-sync workflows rather than a special case.
- scripts/bump-homebrew-cask.sh does the upstream PR from a maintainer's shell,
  re-verifying sha256 and the notarization ticket against the live asset first,
  and refusing to submit if either fails. Verified against v1.13.1: the sha256
  matches and it correctly refuses on the missing notarization ticket.

Drop HOMEBREW_TOKEN from the secret inventory, and rewrite the two formula
TODO(bootstrap) notes that proposed reintroducing it so a future homebrew-core
submission follows the same local pattern.
2026-07-29 10:19:57 -04:00
Claude f98859afa8 feat: enlarge the Amethyst gem in reaction, repost and code icons
Bumps the gem so the brand mark reads bigger inside each glyph: the heart
knockout (6.7 -> 8.2), the repost loop center (5.4 -> 6.9) and between the
code brackets (6.2 -> 7.6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:16:15 +00:00
Claude 3e583fd425 fix: enlarge DM bubble to fill the canvas
The inverted DM bubble read smaller than the original because its body
stopped short and the tail was a small stub. Grow the bubble to the same
footprint as the original glyph, scale the knocked-out gem and the three
chat lines up to match, and give it a full corner tail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:13:15 +00:00
Claude a89aaed9fa feat(buzz): create channels/forums from section-label "+" instead of a FAB
On a Buzz community the FAB (which created a channel) is replaced by a "+" on the
Channels label — matching how Direct Messages already offers a "+" — and a Forums
label gains its own "+" that starts the create flow pre-set to a forum channel.
Both section headers now always render so the "+" is reachable even before any
channel loads; the collapse chevron is shown only when the section is non-empty.

The create route gains an isForum flag (threaded onto RelayGroupCreateScreen →
RelayGroupMetadataViewModel.isForum) so the Forums "+" opens the create screen on
a forum, the Channels "+" on a chat channel. The vanilla NIP-29 relay (a flat
directory with no sections) keeps its FAB. The stale "accept the invite in the
browser" empty text is dropped — an empty community is now a starting point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 14:10:11 +00:00
Claude 6035e5f0e7 fix: clean reaction icon knockout — drop stale inner-heart artifact
The inverted reaction icon carried a leftover inner-heart cutout from the
previous outlined design plus a duplicated gem subpath, so the gem rendered
as a solid island inside a heart-shaped hole instead of a clean knockout.
Regenerated as a solid heart with the Amethyst gem punched straight out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:08:08 +00:00
Claude 4d21165d7d fix: balance DM icon — gem matched to chat-line height, more gap
Shrinks the knocked-out Amethyst gem in the direct-message icon so its
height matches the three chat lines, and shifts it left to widen the gap
between the logo and the lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:05:22 +00:00
Vitor Pamplona 2e47e9cd56 docs: correct the HOMEBREW_TOKEN type and scope
BUILDING.md called for a fine-grained PAT scoped to Homebrew/homebrew-cask.
That token cannot exist: a fine-grained PAT's repository selector only lists
repos owned by the resource owner, so a repo you do not own can never be
selected, and Homebrew's API layer authorises against classic OAuth scopes
(x-oauth-scopes) which fine-grained tokens do not emit.

brew bump-cask-pr forks homebrew-cask into the TOKEN OWNER's account, pushes a
branch there, and opens the PR upstream. Homebrew declares the requirement as
CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"] in utils/github.rb, so it is a classic
PAT with the `repo` scope.

Add the create-token link and call out that `repo` grants write to every repo
the account can reach -- including this one -- so the CI secret should belong to
a dedicated bot account whose only asset is the fork.
2026-07-29 10:04:25 -04:00
Vitor PamplonaandGitHub 09f2ec79e7 Merge pull request #3798 from davotoula/fix/code-review-followups
Address buzz CLI review findings; pin the wrapper copies and the amy arg surface
2026-07-29 10:03:11 -04:00
Claude ab5e4116ad feat: invert DM + reaction icons — knock the gem out of a solid shape
Per design feedback, the direct-message and reaction icons now invert:
instead of an outlined shape with a filled gem inside, the shape is solid
and the Amethyst gem is punched out of it (evenOdd knockout).

- Direct message: solid speech bubble; the gem is knocked out full-height
  on the left, with three chat lines knocked out on the right to read
  clearly as a conversation. The inner rectangle cutout is gone.
- Reaction: solid heart with the gem knocked out of its center.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:02:01 +00:00
Vitor Pamplona 8a8f58e7ad fix(release): actually notarize the macOS DMG, and verify it
The macOS leg ran only `packageReleaseDmg`, which SIGNS the DMG but does not
notarize it — notarization is a separate Compose task. The `notarization {}`
block in desktopApp/build.gradle.kts only supplies credentials; nothing invoked
it. So every release up to v1.13.1 shipped a signed but unnotarized DMG:
`xcrun stapler validate` reports no ticket on either the .app or the .dmg, and
Gatekeeper blocks it on first launch.

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

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

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

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

Correct BUILDING.md's macOS state table, which listed a
com.vitorpamplona.amethyst.desktop.plist that does not exist and omitted
~/.amethyst. Note that Java's Preferences API writes to a SHARED
com.apple.java.util.prefs.plist, which is why the cask must not zap it.
2026-07-29 09:59:41 -04:00
Claude 0e48a8c85b feat: brand push-notification icons with the Amethyst gem
Every per-category status-bar icon now carries the Amethyst crystal so a
notification reads as an *Amethyst* reply/zap/mention at a glance, not a
generic Material glyph. Android renders small icons as a flat alpha-only
silhouette, so the gem is composed as a spatially distinct mark rather than
layered behind the glyph:

- Direct message, mention, repost, reaction, code and badge: gem sits in
  the natural cavity of the glyph (bubble, @ ring, repeat loop, heart,
  brackets, medal center).
- Media: gem is the sun rising over the hills.
- Article: gem is the drop-cap starting the first line.
- Chess: switched to the outlined knight from the app's icon set
  (MaterialSymbols.ChessKnight) with a small brand gem.
- Reply and zap: open glyphs that can't hold a centered gem keep a small
  gem corner mark.

Action buttons stay unbranded — a gem on a "Reply"/"Mark read" button is
noise, not identity — so those now use dedicated plain glyphs
(ic_action_reply, ic_action_mark_read).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 13:58:10 +00:00
Vitor PamplonaandGitHub 37b3d650e0 Merge pull request #3803 from vitorpamplona/claude/feed-kind-toggles-home-4cjyvp
Add Pictures, Videos, Shorts, and Torrents feed types
2026-07-29 09:52:59 -04:00
Vitor PamplonaandGitHub b0c0f12591 Merge pull request #3801 from vitorpamplona/chore/bump-amy-formula-v1.13.1
chore: sync amy Homebrew formula to v1.13.1
2026-07-29 09:45:01 -04:00
Vitor PamplonaandGitHub a5d59a69c3 Merge pull request #3802 from vitorpamplona/chore/bump-geode-formula-v1.13.1
chore: sync geode Homebrew formula to v1.13.1
2026-07-29 09:44:51 -04:00
davotoulaandClaude Opus 5 592fd8f542 test: pin the amy argument surface the buzz-agent path depends on
Flag names and error strings are the contract between amy and the wrapper
scripts (and their operators), but nothing type-checks them: renaming --base-ref
or rewording an error breaks callers while compiling clean. This harness pins the
ones the buzz-agent path drives.

14 cases, all offline against an isolated ~/.amy via the `amy.home` seam the JVM
tests use: the shared `repo-naddr-or-coordinates` positional on git
browse/cat/log, `pass --channel GID` on the workflow verbs, --base-ref accepted
while --base-reff is rejected on both agent up and agent serve, and the
no_relays detail.

Confirmed to discriminate: 14/14 on this branch, 10/14 when built against
main-upstream's BuzzCommands, with the four failures printing the old misleading
"no relays: pass --relays ws://…" for input the user did pass.

Worth recording from writing it: a bare word like `garbage` is *accepted* as a
relay (the normalizer prepends wss://), so the realistic trigger for the empty
set is a pasted http:// url — which is what the cases use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-29 15:42:48 +02:00
Vitor Pamplona a302b82413 fix(ci): replace the unresolvable homebrew-bump-cask action with brew itself
bump-homebrew.yml pinned macauley/action-homebrew-bump-cask to
ad984534de44a9489a53aefd81eb77f87c70dc60, commented "v4.0.0". That SHA does not
exist — the action's only release is v1 (445c4239) from 2021. GitHub resolves
every `uses:` during "Set up job", so the job died before running a single step,
which no `if:` gate can avoid. It stayed invisible because the workflow had
never been triggered; fixing the trigger surfaced it immediately.

Call `brew bump-cask-pr` directly instead. It is the same command BUILDING.md
already documents for manual recovery, and it removes an unmaintained
third-party action that would hold a PAT with write access to homebrew-cask.

Split the workflow into check (ubuntu) + bump (macOS): cask commands are
macOS-only, but the bump is gated on a cask that does not exist yet, so the
10x-billed macOS job is only created once there is something to bump.
2026-07-29 09:42:20 -04:00
davotoula 42bf2a43d8 fix: address code-review findings on the buzz CLI + wrapper scripts
Three findings from the review of the Sonar literal-extraction pass. None were
introduced by that pass — it renamed or sat next to each one.

no_relays told the wrong story. `relaysFor` returns an empty *non-null* set when
--relays was given but every entry failed `normalizeGroupRelay`, so the elvis to
`outboxRelays()` never fires and the user was told to pass the flag they had just
passed. The message now names the real problem and echoes the rejected value; the
`no_relays` error code is unchanged.

`participants` was string-munged. `arr.toString().trim('[',']').split(",")` turns
any shape other than a flat string array into plausible-looking fake pubkeys, and
splits a comma-bearing string into two entries. Now decoded as a JsonArray, with
non-string elements skipped rather than stringified. Extracted to
`participantsOf` so it is testable; BuzzParticipantsTest covers the shapes and
was confirmed to fail (3 of 5 cases) against the old implementation.

Nothing kept the two copies of the buzz-agent wrappers in sync. They exist as
byte-identical trees under cli/src/main/resources/buzz-agent (what `agent up`
extracts) and tools/buzz-agent (what the README tells people to use), with no
build step or test pinning them — I had to hand-apply the same patch twice this
week. BuzzAgentWrapperSyncTest now asserts it. The tools/ tree is declared as a
`:cli:test` input, without which Gradle calls the task up-to-date after a
tools/-only edit and misses exactly the drift being guarded (verified both ways).
2026-07-29 15:42:20 +02:00
Claude 3ab87dd48e fix: don't hijack vertical scroll with swipe-to-reply in chats
The chat bubble used detectHorizontalDragGestures, which claims the pointer
as soon as horizontal movement crosses touch-slop regardless of how much
vertical movement is happening. A mostly-vertical scroll with a little
diagonal drift got grabbed as a swipe, so the bubble slid sideways while the
user was just trying to scroll the conversation.

Replace it with a custom awaitEachGesture detector that only consumes the
pointer once the motion is clearly more horizontal than vertical
(abs(over.x) > abs(over.y)). A predominantly-vertical drag leaves the pointer
unconsumed, so the enclosing scroll keeps it and the bubble stays put. Once
committed to a horizontal drag, behavior (clamp, haptic tick at threshold,
release-past-threshold to reply, settle-back animation) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
2026-07-29 13:39:24 +00:00
vitorpamplonaandgithub-actions[bot] 54b5ff8876 chore: sync geode Homebrew formula to v1.13.1 2026-07-29 13:39:22 +00:00
vitorpamplonaandgithub-actions[bot] 5a665950b8 chore: sync amy Homebrew formula to v1.13.1 2026-07-29 13:38:36 +00:00
Vitor Pamplona 054dffd55d fix(ci): make the Homebrew/Winget bump workflows actually fire
All four bump workflows triggered on `release: types: [released]`, which never
fires here: create-release.yml publishes the release with GITHUB_TOKEN, and
GitHub suppresses workflow-triggering events for GITHUB_TOKEN actions. They had
zero runs across every release up to v1.13.1.

Switch them to `workflow_run` on "Create Release Assets" completion, filtered to
a successful tag push. That also removes a latent race: `released` fired while
the matrix legs were still uploading assets, whereas workflow_run fires after
all of them finish.

The workflow_run payload carries no draft/prerelease flags, so add a
resolve-release composite action that reads them back from the API and feeds
assert-stable-release, keeping the defense-in-depth guard intact instead of
inferring stability from the tag string alone.

Also gate the cask/winget bumps on the package existing upstream. Neither
`amethyst-nostr` nor `VitorPamplona.Amethyst` has been bootstrapped, and
bump-cask-pr/winget-releaser can only update an existing package — without the
gate, fixing the trigger would file a spurious [release-ops] issue on every
release.

Docs: correct the claims this uncovered — Homebrew/Winget are not shipping,
macOS is arm64-only (no Intel DMG), the release carries 31 assets (13 Android,
not 12), Maven Central publishes from a step inside deploy-android and lags
repo1 by tens of minutes, RELEASE_NOTES_ID is minor-releases-only, and note the
git-credential-manager hang that blocks the release push.
2026-07-29 09:37:24 -04:00
Claude 9a5b36d0ba fix(buzz): honor the relay's channel_deleted signal so deleted channels leave the list
The first pass only recorded a deletion when THIS device published the 9008, so a
channel deleted on another Buzz client — or before the fix existed — kept showing
and survived restarts. The Buzz relay's handle_delete_group soft-deletes the
channel and its 39000/39001/39002 discovery events but emits NO member-removed
notification and never retracts the kind-44100 that seeds the browse list; its
only cross-device signal is the relay-signed kind-40099 system message with
type=channel_deleted (verified against block/buzz side_effects.rs).

Consume that signal: LocalCache records a relay-signed 40099 channel_deleted into
RelayGroupDeletions (gated on isRelaySignedGroupEvent so a spoofed one can't hide a
channel), and BuzzRelayImportViewModel.discover now also fetches the #h-scoped
40099 for its candidate channels and drops deleted ones — the relay keeps
re-announcing their 44100 with blank metadata, so this is the only place they
leave the list. Cross-device and retroactive: the relay replays the 40099 on
subscribe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 13:25:37 +00:00
Claude b666a189ac feat(home): split Videos into long-form Videos and Shorts toggles
Separate the Home feed video toggle into two independent groups,
matching how the app already classifies them (see ShortsFeedFilter):

- Videos (long-form): normal (21) + horizontal (34235)
- Shorts: short (22) + vertical (34236)

Each is its own Settings › Home tile with distinct icon/string. The
relay assembler filters and New Threads DAL already list all four
kinds, so only the HomeFeedType grouping changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
2026-07-29 13:22:12 +00:00
Vitor PamplonaandGitHub 7d26c06fe9 Merge pull request #3799 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 09:10:52 -04:00
Vitor PamplonaandGitHub 0ae90dbdc6 Merge pull request #3797 from davotoula/fix/sonar-case-default-clause
Harden workflow-ship.sh and pin it with a regression harness
2026-07-29 09:09:03 -04:00
vitorpamplonaandgithub-actions[bot] 8d877ea70d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 12:33:41 +00:00
Vitor PamplonaandGitHub 7ba5631ec3 Merge pull request #3796 from davotoula/fix/hls-strip-ll-tags
Work around a fatal media3 crash on Low-Latency HLS (LL-HLS), eg NoGoodRadio
2026-07-29 08:30:39 -04:00
Vitor PamplonaandGitHub 1133560dd6 Merge pull request #3795 from nrobi144/feat/desktop-profile-parity
Desktop: profile screen parity — tabs, header actions, banner/bio, zaps
2026-07-29 08:30:02 -04:00
David KasparandGitHub 842a01ea2f Merge pull request #3794 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 14:28:47 +02:00
davotoula fcbd9d727b test: add a regression harness for workflow-ship.sh 2026-07-29 14:14:39 +02:00
davotoula 30c7dbc2f3 Code review:
1. `title="$(git log -1 … | cut …)"` aborts the whole script under
   `set -euo pipefail` when the worktree has no commits
2. `base_branch` only fell back to `main`
2026-07-29 14:14:28 +02:00
davotoula 4700e51d82 fix: add explicit default case to the workflow-ship branch guard 2026-07-29 14:13:51 +02:00
davotoulaandClaude Opus 5 c26988f399 Revert "TEMP: HlsVerify error-level probes for on-device verification"
This reverts commit 76b3323583. The probes did their job.

Verified on a Pixel 9a / Android 17 against beam.mapboss.co.th — a
third-party LL-HLS packager, unrelated to zap-stream-core, so this is not
tuned to one vendor's output:

  PARSE STRIPPED 2360B of 3426B  chunklist_0_video_..._llhls.m3u8
  PARSE STRIPPED 3002B of 4068B  chunklist_2_audio_..._llhls.m3u8

26 reloads of each rendition over 50s of continuous playback, ~70% of every
playlist removed, and zero DataSpec.subrange occurrences — the crash that
androidx/media#3350 tracks.

Both branches confirmed. Eight ordinary HLS streams logged "no LL tags" and
took the identity path untouched, so the stripper is inert on normal
playlists. Routing was correct on ~15 distinct real URLs:
`hls=true -> HlsMediaSource` for application/x-mpegURL, and
`hls=false -> ProgressiveMediaSource` for video/mp4 — which is the
isHlsMediaItem coverage that unit tests cannot provide.

The 18 ERROR states in the same capture were all unrelated: dead URLs
(403/404/502/504), four UnknownHostException from a network drop, and two
UnrecognizedInputFormatException from a youtu.be link on the progressive
path, which ExoPlayer has never been able to play directly.

The pure predicates keep their unit tests, which are the durable guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uE2Db4pzmhi5cFKKkyq2m
2026-07-29 13:56:15 +02:00
davotoulaandClaude Opus 5 76b3323583 TEMP: HlsVerify error-level probes for on-device verification
DO NOT MERGE — revert this commit before the branch goes anywhere.

PLAYBACK_DIAG_TAG logs at DEBUG and Amethyst.onCreate sets
`Log.minLevel = if (BuildConfig.DEBUG) DEBUG else ERROR`, so nothing on that
tag survives in the benchmark build. Three logcat captures during this work
had zero PlaybackDiag lines for exactly that reason, which left the routing
decision unverifiable — the fix was only ever confirmed indirectly, by the
crash no longer happening.

Adds one ERROR-level tag, HlsVerify, on the three paths unit tests cannot
reach:

  - CustomMediaSourceFactory.createMediaSource — the routing decision, so
    isHlsMediaItem's verdict and the chosen factory are visible. This is the
    predicate with no test coverage, since android.net.Uri stubs to null
    under unitTests.isReturnDefaultValues.
  - LowLatencyStrippingParser.parse — whether the stripper actually engaged
    and how many bytes it removed, so a silent stop-stripping regression is
    visible rather than inferred.
  - HlsLivenessRecorder.maybeRecord — the other consumer of the shared
    predicate, unreachable without a Player.

The pure predicates underneath are already unit-tested and remain the durable
regression guard; this is only for confirming the wiring on a device.

Capture with `adb logcat -s HlsVerify:E`. Remove with `grep -rn HlsVerify` or
by reverting this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uE2Db4pzmhi5cFKKkyq2m
2026-07-29 13:37:16 +02:00
davotoula cabe539e8e Code review:
- retire the `.m3u8` substring predicate across the UI
- cover the preload hint and the byte/charset layer
- share one HLS predicate with the liveness recorder
- unify the HLS predicate and track the media3 bug upstream
2026-07-29 13:30:36 +02:00
davotoula 6f5e0c6a6b fix(playback): strip Low-Latency HLS tags before media3 parses the playlist
media3 crashes fatally on an LL-HLS playlist whose parts are byte ranges —
what zap-stream-core emits (`#EXT-X-PART:...,BYTERANGE="359712@0"`).
Reproduced on media3 1.10.1, Pixel 9a / Android 17, ~0.6s after
ExoPlayerImpl.Init:

    IllegalArgumentException
      at DataSpec.<init>            // checkArgument(length > 0 || length == LENGTH_UNSET)
      at DataSpec.subrange
      at HlsMediaChunk.feedDataToExtractor
      at HlsMediaChunk.loadMedia
2026-07-29 13:30:36 +02:00
nrobi144andClaude Opus 4.8 c5ba33486a feat(desktop): format profile zap amounts compactly (210k, 1.5M)
Use commons showAmount() for the Zaps tab total and each zapper row instead of
raw sats, matching Amethyst Android and other Nostr clients.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:55 +03:00
nrobi144andClaude Opus 4.8 b2b5bccec1 docs: desktop profile parity plan + per-phase sub-plans
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:55 +03:00
Claude 646459e367 fix(buzz): remove a deleted channel from the community channel list
Deleting a channel (kind-9008) published the delete and dropped it from the
user's kind-10009 list, but the community's browse list is built from the
cached kind-39000 metadata and the Buzz membership (kind-44100) set — neither
of which the delete touched — so the channel lingered in the list, and a stale
44100 re-announcement could bring it back after a restart.

Track deleted relay-group channels in a device-global RelayGroupDeletions
registry (keyed by GroupId.toKey, so it stays relay-scoped), persist it via
RelayGroupDeletionPreferences, mark the channel on deleteRelayGroup, and filter
deleted keys out of both the directory channels and the Buzz membership list in
RelayGroupChannelListScreen. The delete now removes the row live and it stays
gone across restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 05:44:28 +00:00
Claude f46534a219 feat(home): add Pictures, Videos and Torrents to Home content-type toggles
Extend the per-content-type Home feed toggles to the remaining root
feed (non-chat) kinds that render as cards but weren't yet part of the
Home feed:

- Pictures (NIP-68, kind 20)
- Videos (NIP-71: normal 21, short 22, horizontal 34235, vertical 34236)
- Torrents (NIP-35, kind 2003)

Each gets a HomeFeedType group (kinds stay disjoint), is added to the
always-on home relay assembler filters, and is accepted by the New
Threads DAL (which also feeds the Everything tab). The addressable
video kinds are registered in the DAL's addressable-kind scan. Wired
into Settings › Home with icons/strings; toggling still drops the kinds
from both the relay REQ and the rendered feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
2026-07-29 05:33:13 +00:00
vitorpamplonaandgithub-actions[bot] b575f9078d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 03:56:46 +00:00
Vitor PamplonaandGitHub 52a8cc338a Merge pull request #3793 from vitorpamplona/claude/highlight-events-browser-ywz5wr
Add NIP-84 highlights support: composer, feed, and share intent
2026-07-28 23:53:41 -04:00
Claude 7927e1de1b feat(highlights): rich comment field + source-event preview in composer
Address composer feedback:
- The annotation field is now the short-note composer's rich MessageField
  (@-mention + custom-emoji autocomplete, inline previews). On publish it becomes
  the highlight's `comment` tag, with the mentions (`p`), emoji, URLs (`r`),
  hashtags and quotes it references emitted as their own tags — via
  NewMessageTagger + a new tag-builder initializer on HighlightEvent.build().
- A nostr source now renders as a reply-style preview card (NoteCompose, quoted
  style) instead of showing a URL field; the URL field appears only for web
  sources. The source note is resolved from the a/e coordinate carried in the
  route.

Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
2026-07-29 03:38:29 +00:00
Vitor Pamplona 1bda3ab31b v1.13.1 2026-07-28 23:17:02 -04:00
Claude 55cc4ce9dc feat(highlights): highlight-a-note menu action; drop composer preview
- Add a "Highlight" action to the note action menu (3-dot menu + chat long-press
  sheet). It opens the NIP-84 composer pre-tagged with the note as source — an
  `a` reference for an addressable article, else an `e` reference, plus the
  author `p` — with the passage left for the user to type/paste. Prose kinds
  only (text notes, long-form), and never a private rumor (a public highlight
  would leak an e-tag of the unsigned rumor). This is the in-app entry point for
  "post with the source as an event".
- Remove the live preview from the New Highlight screen per review.
- Add two real-world regression tests for the shared-highlight parser: Chrome
  "copy link to highlight" with a prefix/suffix fragment (incl. an encoded
  hyphen inside the prefix) and a long start-only fragment with encoded commas.

Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:50:53 +00:00
Claude 155ef8e46c feat(highlights): richer composer UI and full NIP-84 source coverage
Redesign the New Highlight composer as a pull-quote you craft: a rounded tonal
hero card with a quotation-mark watermark, a highlighter-amber accent bar, and
the passage in large type, plus a live highlighter-pen preview (reusing the feed's
HighlightedQuote) and icon-led source/note fields.

Also extend HighlightEvent.create()/build() to emit a/e/p tags, so the builder
now covers every NIP-84 source — a web page (r), a nostr article (a), a nostr
note (e), each with optional author attribution (p) and context — not just web
highlights. Route.NewHighlight and the composer ViewModel carry the nostr source
(address/event/author) and context through so a future in-app "highlight this
passage" action can open the composer for a nostr article/note.

Covered by a new builder test for the a/e/p tags; verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:27:49 +00:00
Vitor PamplonaandClaude 9189955155 fix(quartz): drop the paren the URL trim orphans in the passage
`trimUrlEnd` strips a wrapping `)` off the URL token, but the `(` it opened
stayed at the tail of the passage, so sharing
`See this quote (https://example.com/article)` published a highlight whose
content read `See this quote (`.

Trim an unbalanced bracket left at the end of the passage after the URL token
is removed. Only an unbalanced one at the very end goes, so `He said (see
below) https://…` keeps its matched pair, and `.../Mercury_(planet)` — where
nothing was trimmed off the URL — is untouched on both sides.

The existing paren tests only asserted `result.url`, which is why this slipped
through; the new cases assert `result.quote` too, including a regression guard
for the slug-paren direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 02:22:07 +00:00
Claude 5b07817cf8 feat(android): Highlights feed with top-nav filters and add button
Adds a browsable Highlights feed (NIP-84 kind:9802) modeled file-for-file on
the Git Repositories feed pattern, since highlights are announcement-like
regular events with the same follow-list top-nav filtering.

New feed stack under ui/screen/loggedIn/highlights:
- dal/HighlightsFeedFilter — AdditiveFeedFilter scanning LocalCache.notes for
  kind 9802 (regular events, so notes rather than addressables), gated by the
  selected top-nav follow list via FilterByListParams.
- datasource/ assembler + compose subscription + PerUserAndFollowList sub-
  assembler + makeHighlightsFilter dispatcher, with per-relay subassemblies for
  follows / authors / muted / global / hashtag / geohash. Communities aren't a
  highlight concept, so those sets fall through and aren't offered.
- HighlightsScreen (DisappearingScaffold + shared RenderFeedContentState; the
  existing RenderHighlight card handles rendering) with a HighlightsTopBar
  FeedFilterSpinner and a NewHighlightButton FAB into Route.NewHighlight.

Wiring into the shared plumbing mirrors gitRepositories exactly: Route.Highlights
+ AppNavigation, NavBarItem enum/catalog/drawer lists (FormatQuote icon, already
in the subset font), AccountFeedContentStates (feed state + update/delete/trim),
RelaySubscriptionsCoordinator, TopNavFilterState.highlightsRoutes,
AccountSettings.defaultHighlightsFollowList + changer, Account live follow-list
flows, LocalPreferences persistence, ScrollStateKeys, and the bottom-bar
preloader. Verified with :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:22:07 +00:00
Claude 927cc9449c fix(quartz): keep balanced trailing parens in shared highlight URLs
The URL trailing-punctuation trim now leaves a closing bracket in place when
it is balanced within the token, so a shared Wikipedia link like
`.../wiki/Mercury_(planet)` keeps its `)` while a wrapping `(https://…)` still
loses the paren the surrounding prose added.
2026-07-29 02:22:06 +00:00
Claude 1f35339866 feat(android): share browser selection to a NIP-84 highlight composer
Adds a "New Highlight" share target and composer so a user can select text in
a browser, hit Share → Amethyst, and publish it as a kind:9802 highlight.

- Manifest: a text/plain-only ShareAsHighlightAlias activity-alias, matched at
  runtime by ShareIntentRouting.isShareAsHighlight (mirrors the Send-as-DM
  alias pattern).
- AppNavigation: both the launch-intent and warm onNewIntent parse blocks now
  branch on the highlight alias, run the shared text through
  SharedHighlightParser, and open Route.NewHighlight pre-split into
  quote/url/prefix/suffix.
- NewHighlightScreen + NewHighlightPostViewModel: a trimmed composer (passage,
  source URL, optional note — no polls/zaps/media/scheduling) that publishes via
  account.signAndComputeBroadcast(HighlightEvent.build(...)).

Also adds HighlightEvent.build(), the unsigned EventTemplate counterpart of
create(), for the sign-and-broadcast pipeline. Verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:22:06 +00:00
Claude 69c3e3accc feat(quartz): parse browser shares into NIP-84 highlights
Adds a shared-highlight parsing layer under nip84Highlights/parse that turns
the free-form text a browser hands Amethyst on "Share selection" into the
pieces of a kind:9802 highlight:

- SharedHighlightParser normalises selection-only, selection+URL, URL-only and
  "link to highlight" (#:~:text= fragment) shares into a SharedHighlight.
- TextFragmentParser decodes/strips WICG text-fragment directives (prefix,
  start, end, suffix), leaving literal '+' verbatim.
- UrlTrackerCleaner strips utm_*/fbclid/etc. from the source URL per NIP-84's
  "clean the URL from trackers" guidance, preserving path and fragment.

Also adds a HighlightEvent.create() builder overload that assembles the r,
textquoteselector, context and comment tags from parsed data, centralising
what the desktop publish action does by hand.

Covered by commonTest suites for each piece plus a builder round-trip.
2026-07-29 02:22:06 +00:00
Vitor PamplonaandGitHub 87b83a7c16 Merge pull request #3792 from vitorpamplona/claude/highlight-author-attribution-25e4nb
Fix highlight author detection and improve source display
2026-07-28 22:21:02 -04:00
Vitor PamplonaandGitHub 551add4be0 Merge pull request #3791 from vitorpamplona/claude/highlighted-text-richtext-parser-88ghgv
Pass comment tags to DisplayHighlight for proper rendering
2026-07-28 22:16:53 -04:00
Claude 3eb8914c3e fix(highlights): attribute to the author-marked p tag, drop alt captions
A kind:9802 highlight that mentions users before its author rendered the
wrong name and a stray "published by …" caption.

- HighlightEvent.author() took the first p tag regardless of role, so a
  highlight with leading "mention" p tags was attributed to a mention
  instead of the "author"-marked one. Prefer the NIP-84 author marker,
  falling back to the first p tag for highlights (including Amethyst's own)
  that omit the marker.

- DisplayEntryForNote used the source note's title/subject/alt as a caption.
  For a kind-1 note there is no title/subject, so it fell to the NIP-31 alt
  tag — which clients like Jumble fill with a generic "This event was
  published by https://jumble.imwald.eu." line. Drop alt from the lookup and
  name the source by its event kind instead, kept clickable to the note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJh3QYonEa9hw6mJcnrmve
2026-07-29 02:07:11 +00:00
Claude 74be87c305 fix: render custom emoji in highlight comments
The NIP-84 highlight comment was passed to TranslatableRichTextViewer with
an empty tag list, so custom emoji (:shortcode:) never resolved against the
event's `emoji` tags and rendered as raw text.

Thread the highlight event's tags through to the comment's rich-text viewer
so the emoji map is built from them, matching how regular text notes render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyyodHWxSHeiY6pndPxGSb
2026-07-29 02:04:05 +00:00
Vitor PamplonaandGitHub ebf04de516 Merge pull request #3790 from vitorpamplona/claude/home-screen-event-toggles-q75xwc
Add per-content-type toggles for Home feed event kinds
2026-07-28 20:55:56 -04:00
Vitor PamplonaandGitHub 79e70e1161 Merge pull request #3789 from vitorpamplona/claude/buzz-media-upload-decode-gc5hxm
Add BUD-01 read-auth retry for gated Blossom blob downloads
2026-07-28 20:40:00 -04:00
Vitor PamplonaandGitHub 3411f75c4a Merge pull request #3788 from vitorpamplona/claude/nip29-wisp-relay-test-dloy9s
Support NIP-29 group relays in first-party AUTH logic
2026-07-28 20:38:50 -04:00
Claude defb898987 refactor: use Hex.isHex64 instead of a regex for the blob-id check
blossomHashOrNull runs on every media URL the feed loads. Quartz's unrolled
Hex.isHex64 is the fast path for validating a 32-byte hex id (~30% faster
than isHex, far faster than a regex match). It only checks the first 64
chars and doesn't verify length, so keep an explicit length == 64 guard to
reject longer segments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
2026-07-29 00:35:53 +00:00
Claude 7d1c45607b perf: preemptively sign Blossom reads for known auth-gated hosts
The retry-on-401 interceptor re-probed anonymously on every blob, so each
image from an auth-gated host (e.g. a Buzz community feed, where nearly all
media is on one host) paid a wasted 401 round trip before the signed retry.

Remember hosts that answered 401 and attach the cached token up front on
their subsequent blobs — one round trip instead of two in steady state. The
first blob per host still costs the probe; the learned set falls back to an
anonymous request when no signer is available so a logged-out user never
loops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
2026-07-28 23:54:31 +00:00
Vitor Pamplona bcfe2745f2 Merge remote-tracking branch 'upstream/main' 2026-07-28 19:50:55 -04:00
Vitor PamplonaandGitHub 723c90c0d7 Merge pull request #3787 from vitorpamplona/claude/geohash-single-level-precision-gise2y
Add CONTINENT geohash level for whole-globe location channels
2026-07-28 19:47:52 -04:00
Vitor PamplonaandClaude Opus 5 096fd0833a Merge PR: fix(desktop): cache OS Keyring handle so startup only prompts once
Merges nostr proposal ed443f87 into main:
- Cache the Keyring behind double-checked locking so the OS backend is
  opened once per SecureKeyStorage rather than per save/get/delete. Cold
  start touches storage at least twice (metadata AES key, then the active
  account nsec), which surfaced as two keychain unlock prompts.
- Add a KeyringHandle seam so the test suite can substitute an in-memory
  backend instead of touching the real OS keychain, plus tests pinning the
  single-open invariant, including a 16-thread concurrent first-touch race.

Sharing one handle across Dispatchers.IO threads is safe on all three
backends: the macOS path resolves to pt.davidafsilva.apple.OSXKeychain,
itself a static singleton with no mutable instance state, so every
Keyring.create() already shared it; the Freedesktop and Windows backends
hold a single final collaborator assigned in their constructor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:46 -04:00
Vitor PamplonaandClaude Opus 5 1a82c9b9c8 Merge PR: fix(desktop): reload the visible page when switching accounts
Merges nostr proposal 22a8247d into main:
- Wrap MainContent in key(account.pubKeyHex) so the account-scoped subtree
  is torn down on an account switch. Deck layout and workspace state are
  remembered outside this key and survive.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:57 -04:00
Claude 7d1caf590a fix: authenticate to NIP-29 host relays of joined groups so private group content loads
A private/closed NIP-29 group serves its content (kind-9 chat, kind-11
threads, …) only over a NIP-42-authenticated connection. Amethyst's
group-content reads are all `#h`-scoped with all authors, so they never
name the user's pubkey, and a group host relay (e.g. wss://chat.wisp.talk)
is not in any of the NIP-65/DM/search/… lists that feed
`account.trustedRelays`. The per-account first-party AUTH gate therefore
returned false and Amethyst never AUTHed, so the relay refused the content
with `auth-required` — the group's public 39000 metadata still loaded, so
the group appeared but showed no messages.

Treat a NIP-29 relay group the user explicitly joined (their kind-10009
list) as a first-party reason to authenticate with its host relay,
mirroring the existing `BuzzWorkspaces.isJoined` carve-out. Reads of a
group the user has not joined (e.g. browsing a relay's public directory)
still do not AUTH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018puT2YnevbLHYG2PdCLAeh
2026-07-28 23:45:49 +00:00
Claude 493aed8609 feat: sign Blossom read-auth to view media on auth-gated hosts
Buzz's private media relay (*.communities.buzz.xyz) gates blob downloads
behind BUD-01 read auth, returning `401 {"error":"authentication failed"}`
to anonymous GETs. Amethyst loaded every media URL anonymously through
Coil/OkHttp, so those images (and their thumbnails) never decoded.

Quartz already had BlossomAuthorizationEvent.createGetAuth but nothing in
the app ever called it. Wire it into the media HTTP client:

- BlossomReadAuthInterceptor: on a 401 for a GET whose URL last segment is
  a Blossom sha256 filename (covers `<hash>.png` and `<hash>.thumb.jpg`),
  retry once with a signed `Authorization: Nostr <event>` header. Narrowly
  gated so unrelated 401s never trigger a second request or any signing.
- BlossomReadAuthTokenProvider: bridges the suspend signer synchronously
  (runBlocking + timeout so a slow remote/external signer can't pin the
  OkHttp thread) and caches one server-scoped token per host, which also
  covers derived blobs like thumbnails.
- BlossomAuth.createGetAuth exposes the read-auth builder to the app layer.

Public Blossom/NIP-96 hosts stay zero-overhead: they answer 200, so no
token is ever signed for them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
2026-07-28 23:25:57 +00:00
Claude cdfc275891 feat(home): add per-event-kind toggles for the home feed
Add a "Content in the feed" section to the Home settings screen with a
switch per event-kind group (text notes, reposts, comments, articles,
polls, voice, live activities, chess, ...). Disabling a group both drops
its kinds from the always-on home relay filters (the assembler) and hides
them from the New Threads / Conversations / Everything tabs (the DAL).

- HomeFeedType: single source of truth mapping each toggleable group to
  its Nostr kinds, with stable codes + encode/decode for persistence
  (mirrors the existing ChatFeedType pattern for Messages).
- AccountSettings.enabledHomeFeedTypes (+ setHomeFeedTypeEnabled),
  persisted per-account as the set of disabled codes so new groups
  default on.
- Assembler: HomeOutboxEventsEoseManager strips disabled kinds from every
  home relay filter at the single choke point, and re-arms on toggle.
- DAL: HomeNewThreadFeedFilter / HomeConversationsFeedFilter reject
  disabled kinds; AccountFeedContentStates rebuilds the home feeds when a
  toggle flips.

Also extracts the inbox / relay-auth / feed-type preference reads out of
LocalPreferences' account-load lambda into a helper: that lambda was
already at the JVM per-method bytecode limit and the new field tipped it
over ("Method too large").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L576BhgU3c638PkGHL8YUS
2026-07-28 23:22:50 +00:00
Vitor PamplonaandGitHub 26e09339b5 Merge pull request #3786 from vitorpamplona/fix/import-nits-merged-proposals
style: import the symbols the two merged nostr proposals referenced inline
2026-07-28 19:21:40 -04:00
Claude 5f3848c47d feat: allow single-char (continent) geohash precision in location channels
Add a CONTINENT(1) level to GeohashChannelLevel so the coarsest selectable
geohash channel is a single character (~5000 km, one of 32 cells for the
globe), previously floored at REGION(2). Every precision picker iterates
GeohashChannelLevel.ordered, so the map picker, Teleport, "Near me" list and
New-geohash-chat all pick it up automatically; GeohashChatsScreen now labels a
1-char cell "Continent" instead of showing no level.

Adds the "Continent" label and "~5000 km" chip subtitle, and extends the
GeohashChannelLevel test coverage. REGION..BUILDING (2-8 chars) still mirror
the Bitchat channel levels; CONTINENT is an Amethyst extension beyond them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtvo2iNSnG3M21QwbFPznU
2026-07-28 23:20:54 +00:00
Vitor PamplonaandClaude Opus 5 9e69198623 style: import the symbols the two merged proposals referenced inline
Both nostr proposals merged just now (82e72369, bdb4b03e) referenced types
by fully-qualified name inside function bodies, which CLAUDE.md's Kotlin
style rule forbids. Merged them as authored rather than rewriting someone
else's patch mid-merge; this is the follow-up.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:15:29 -04:00
Vitor PamplonaandClaude Opus 5 4e42528591 Merge PR: fix(namecoin): don't leak lookup exceptions through resolve()
Merges nostr proposal bdb4b03e into main:
- Catch NamecoinLookupException in performLookup and return null, restoring
  resolve()'s documented "null on any failure" contract. nameShowWithFallback
  throws NameNotFound / NameExpired / ServersUnreachable, which escaped
  resolve() and reached Nip05State.checkAndUpdate as a hard error.
- CancellationException is rethrown first so coroutine cancellation is not
  swallowed.
- resolveDetailed() is unaffected: it goes through performLookupDetailed(),
  which still distinguishes each failure reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:06:54 -04:00
Vitor PamplonaandClaude Opus 5 02c2bea1f3 Merge PR: fix(desktop): make Enable OS notifications button actually enable them
Merges nostr proposal 82e72369 into main:
- Provide LocalNotificationSettings / LocalNotificationReadState at the app
  root. Both CompositionLocals were already consumed by NotificationsScreen
  and NotificationSettingsScreen but never provided, so each screen fell back
  to its own PreferencesNotificationSettings(); disk prefs stayed in sync but
  the in-memory StateFlow updates never crossed instances.
- Flip the master toggle when the OS permission is granted, so the "Enable OS
  notifications" button matches its label end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:06:05 -04:00
Vitor PamplonaandGitHub ddbee398ca Merge pull request #3785 from vitorpamplona/fix/buzz-channel-row-background
fix(buzz): drop the grey slab behind the community Channels section
2026-07-28 18:48:08 -04:00
Vitor PamplonaandClaude Opus 5 15a389b155 fix(buzz): separate community rows with a hairline
Removing the per-row Cards also removed the only thing separating adjacent
rows. Restore that with the 0.25dp outlineVariant hairline the vanilla
NIP-29 branch of this same screen and the Concord server list already use.

Drawn before each row except the first, so a section's own header keeps
providing the separation at its boundaries — no stray line under the last
channel or above "Direct Messages". Applied to every row list on the screen
(chat channels, forums, inline DMs, hidden DMs) rather than just the
Channels section, so the screen stays one consistent surface; the vanilla
branch now shares the same helper instead of inlining it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:45:13 -04:00
Vitor PamplonaandClaude Opus 5 4c5fe75f0a fix(buzz): drop the grey slab behind the community Channels section
Each channel row was its own filled Material3 Card. They stack with no gaps,
so their container colour merged into one continuous grey block behind the
whole Channels (and Forums) section — reading as a box drawn around the
channels that the Direct Messages rows immediately below, which are plain
rows, didn't have. The two halves of the same screen looked like different
surfaces.

Render the row as a plain Box on the screen background instead, keeping the
click on the container so the whole row still opens the channel. Matches
BuzzDmInlineRow directly below it and the Concord server list.

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

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

Concretely, after this change:

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 08:11:02 +10:00
Vitor PamplonaandClaude Opus 5 fa1a32e91c fix(concord): open a community by tapping its name, not just its avatar
On the Concord home list the row's `clickable` expands/collapses, and the
only way to open a community was a separate `clickable` on the 40dp
avatar — nothing marked it as its own target, and tapping the name (the
thing most people reach for) silently expanded the row instead.

Give the name/subtitle column the same `onOpen` the avatar already has, so
the identity block — icon and title together — opens the community while
the rest of the row and the chevron keep expanding it. Tapping a title to
open the thing it names is the convention users arrive with; the disclosure
control stays the disclosure control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:09:55 -04:00
mandClaude ae3218249a fix(desktop): cache OS Keyring handle so startup only prompts once
`SecureKeyStorage`'s three keyring paths (save/get/delete) each called
`Keyring.create()` on every invocation. Each call opens a fresh backend
session:

- macOS: a new Security Framework session against `login.keychain`.
  Depending on the user's keychain policy (short access window, ACL on
  the amethyst-desktop item, or first-touch after unlock timeout), this
  surfaces a Keychain Access prompt every time.
- Linux Secret Service / KWallet: a fresh session may re-trigger the
  wallet-unlock prompt if the daemon closed the previous session.
- Windows Credential Manager: less user-visible but still redundant.

Amethyst's cold-boot touches the store at least twice — once for
`DesktopAccountStorage`'s AES-256-GCM metadata key
(`account-metadata-key`), then again for the active account's nsec —
so the user was seeing the OS keychain unlock prompt twice in a row
before the UI was reachable.

Fix: memoise the `Keyring` handle for the lifetime of the process. The
`Keyring` object is thread-safe for the three ops we call, so a
double-checked lazy singleton behind `keyringLock` is sufficient. The
NPE hit path is a proper lazy: any `BackendNotSupportedException` bubbles
up on the first call and is caught by the existing outer try/catch,
which flips `keyringAvailable=false` and falls back to the encrypted
file path (unchanged).

Includes a small package-private `KeyringHandle` interface + real
delegator so `SecureKeyStorageKeyringCacheTest` can substitute an
in-memory handle and count backend-open invocations without touching
the OS keychain. Three cases:

1. Cold-boot storm (save/get/delete across metadata + account keys)
   opens the Keyring exactly once.
2. Repeated `hasPrivateKey` reuses the cache.
3. Concurrent first-touches from 16 threads still open the Keyring
   exactly once (double-checked locking is race-free).

No behavioural change beyond the prompt-count fix.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 08:06:02 +10:00
Vitor PamplonaandGitHub ae6f56283b Merge pull request #3782 from vitorpamplona/feat/buzz-messages-toggle
fix(buzz): make the Messages toggle read the list it claims to change
2026-07-28 17:31:21 -04:00
Vitor PamplonaandGitHub c9e79e31f8 Merge pull request #3783 from vitorpamplona/claude/highlight-excess-spaces-0678he
Trim highlight context to bounded window, collapse whitespace
2026-07-28 17:25:17 -04:00
Claude de86e54cf8 fix: trim edge blank lines and skip full scan in highlight windowing
Audit follow-ups on the highlight context window:

- Blank lines sitting at the very start/end of a `context` tag were passed
  through untouched when a side was short enough not to be trimmed, so a
  highlight whose context began or ended with blank lines still rendered
  empty space above/below the quote. Trim the outer edges of the windowed
  passage (re-basing the marked range accordingly).
- `locate` enumerated every occurrence of the quote — a full context scan
  plus a list allocation — even in the common no-prefix case where only the
  first match is needed. Short-circuit to a single indexOf there.
- Clamp the returned marked range to the trimmed text length so it can never
  point past the end (e.g. a quote ending in trimmed whitespace).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
2026-07-28 21:11:37 +00:00
Vitor PamplonaandClaude Opus 5 39f69de5e6 fix(concord): leave room for the FAB in the community channel list
Same defect the relay-group and Buzz DM lists had: the Scaffold's padding
carries the top/bottom bars but deliberately not the FAB (a FAB overlays
content by design), so the last channel row's manager overflow menu sat
underneath it and couldn't be tapped.

As contentPadding rather than a modifier, so rows scroll *through* the
strip instead of the viewport shrinking and leaving the FAB over dead
space. Gated on canManageChannels because that is what renders the FAB —
a plain member has nothing to clear, and no reason to lose the space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:08:40 -04:00
Claude 89aea073ed fix: bound the context shown around a highlight to a window
A kind:9802 highlight can carry a huge `context` tag — a quote pulled from
the middle of a long article may ship several paragraphs of surrounding
text. Rendered whole, that fills the feed card with paragraphs around a
one-sentence highlight.

Trim the context in `HighlightQuote.of` to at most ~160 characters on each
side of the marked quote, snapping the cut to a whole-word boundary and
marking it with an ellipsis. The quote itself is always kept in full and
the marked range is re-based onto the trimmed text, so the in-context
marker still lands exactly on the highlighted passage. Short contexts are
left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
2026-07-28 21:03:56 +00:00
Vitor PamplonaandGitHub 4f65058e87 Merge pull request #3777 from vitorpamplona/claude/lazycolumn-duplicate-key-ahgh60
Fix NoteListMatchingFilter to prevent duplicate entries under concurrent updates
2026-07-28 17:00:37 -04:00
Vitor PamplonaandGitHub a17e23d796 Merge pull request #3781 from vitorpamplona/claude/posts-malformed-r-tags-piu299
Fix bogus URL references from scheme-less prose tokens
2026-07-28 16:56:13 -04:00
Claude 0851768a8b fix: only tag explicit http(s) URLs as r references
Posts were publishing a flood of bogus `r` tags — the v1.13.0 release note
shipped 11 junk references such as `https://.deb/`, `https://window.nostr/`,
`https://kind:30166/` and `https://crowdin.pretended462/`.

The reference extractor `findURLs` fed the raw, scheme-less UrlDetector output
straight into `r` tags. That detector is deliberately eager: to let the
rich-text renderer linkify a bare `example.com`, it also reports every
`word.word`, `word/word` or `word:port` token with no real-TLD whitelist.
Prose is full of those (`.deb`, `.rpm`, `[database].backend`,
`nostr-wallet-connect/nwc`, `~2.5x`, `@mentions`), so each one became a
reference on the published note. The rich-text side already guards against
this via UrlParser (TLD validation + scheme separation); the tag path never
got the same guard.

Require an explicit http/https scheme and a valid TLD before a detected URL
becomes a reference. Rendering is untouched (separate parser), so bare domains
still show as links — they just no longer pollute the tags.

Adds regression coverage over the exact fragments from the v1.13.0 note plus
checks that real, explicitly-schemed links are still extracted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8KXx8UQ6mgyxjBSWW6sQV
2026-07-28 20:51:35 +00:00
Vitor PamplonaandClaude Opus 5 618ef66e75 fix(buzz): make the Messages toggle read the list it claims to change
"Remove from Messages" was hardcoded on every surface, so it never showed
an "Add to Messages" counterpart for a channel that was already off the
list, and the Buzz workspace rows read a session-local snapshot of the
kind-10009 list that was seeded once and only ever grew — a channel taken
off Messages still rendered as a disabled "Added", leaving no way back.

Removal itself always worked (verified on-device: the kind-10009
republished and the row left Messages); what was missing was any read of
that list on the way back.

- RelayGroupListState: expose liveRelayGroupIds, the joined groups as
  normalized GroupIds, so the UI can ask whether a channel is on Messages
  without string-matching a raw relay url another client may not have
  normalized the way we do.
- RelayGroupTopBar / BuzzImportRow: one Add/Remove toggle driven by that
  flow. Remove no longer pops back — you stay a member reading the
  channel, and staying is what makes the entry flip so the action is
  visibly undoable. Leave still pops.
- BuzzRelayImportViewModel: track "added" against the live list instead of
  a one-shot seed, and add remove(); add() now also clears the dismissal
  so a relay's kind-44100 re-announcement isn't filtered back out.
- AccountViewModel: addRelayGroupToMessages() as the counterpart to
  removeRelayGroupFromMessages(); acceptChannelInvite() delegates to it.

Buzz DMs had the same one-way shape for a different reason: hiding is a
relay-side per-viewer flag (kind-41012 -> the kind-30622 snapshot), and
rebuildRows dropped hidden DMs on the floor, so a hidden conversation was
gone for good. There is no unhide command — re-opening is the unhide, a
kind-41010 with the same participants resolving to the same canonical
channel. Hidden DMs are now projected into their own list behind a
collapsible "Hidden (N)" header, faded but still openable, each offering
"Add to Messages". Also added to the community view's inline DM rows,
which had no menu at all and are where DMs actually live — the full inbox
sits behind a "see all" row that only appears above six DMs, so in a small
workspace the hidden section would have been unreachable.

Both list screens now leave bottom room for the FAB, which the Scaffold's
padding deliberately doesn't account for; the last row's overflow menu was
sitting underneath it.

Adds SimpleGroupListEventTest covering the removal path, including that a
renamed channel still matches (removal keys on group id + relay only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:50:49 -04:00
Claude 4d24e2b09c fix: collapse scraped whitespace when reconstructing highlight context
A kind:9802 highlight with no `context` tag falls back to reconstructing
the surrounding passage from the W3C `textquoteselector` prefix/suffix.
Those fragments are scraped from the source web page, so they carry the
page's block-boundary whitespace (runs of newlines/spaces between DOM
nodes). Glued in verbatim as `prefix + content + suffix`, they render as a
stack of blank lines above the marked quote.

Collapse each whitespace run in the prefix/suffix to a single space (and
trim the outer edges) in `HighlightEvent.contextOrReconstructed()`. The
highlight's own `content` is left verbatim so its offsets inside the
reconstructed context stay exact for the in-context marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
2026-07-28 20:47:53 +00:00
Claude 58004fa744 fix: dedup EventListMatchingFilter (observeEvents) and harden emission contract
EventListMatchingFilter had the same mutable-sort-key defect as
NoteListMatchingFilter: it stored Notes in a ConcurrentSkipListSet ordered by
the live created_at, so a newer replaceable version (which mutates the shared
AddressableNote in place) stranded its node and let the same instance be
inserted twice — the emitted event list then carried the same event twice. It
hadn't surfaced as a crash only because its consumers (app recommendations,
relay groups, room reactions) happen to dedup downstream.

Apply the same capture-key + idHex-dedup + per-key compute design, but preserve
EventListMatchingFilter's update-reflecting semantics: an addressable update
re-emits (the snapshot reads the refreshed event live off the note) rather than
being ignored. It keeps the entry's captured position instead of re-sorting —
re-sorting via remove+add let two entries with different captured keys for the
same note transiently coexist and both read the same live event, duplicating it.

Also harden both filters' emission: a ConcurrentSkipListSet iterator is weakly
consistent, so under concurrent add/remove churn a single traversal can
momentarily surface a key twice. snapshot() now dedups by idHex so the emitted
list — the LazyColumn's source of keys — is always unique, regardless of
transient internal states. Corrected the over-claimed "can never hold two"
docstrings accordingly.

Adds EventListMatchingFilterTest mirroring the note tests: update-reflection,
version-note re-emit, sorted order, remove-after-mutation, and two concurrency
stress tests (with/without limit) that failed before this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 20:42:13 +00:00
Vitor PamplonaandGitHub 311b3ec7f0 Merge pull request #3779 from vitorpamplona/claude/buzz-delete-channel-n24icj
Add delete group/channel functionality (kind-9008)
2026-07-28 16:37:17 -04:00
Claude 83ec9b490b feat: allow admins to delete a Buzz channel / relay group
Wire up the existing kind-9008 DeleteGroupEvent, which was parsed on
receipt but never sent. Admins/owners can now delete a whole channel
(Buzz) or group (NIP-29) from the channel overflow menu.

- Account.deleteRelayGroup: sends the kind-9008 delete-group to the
  channel's relay(s) and drops it from the local list.
- AccountViewModel.deleteRelayGroup: signer-dispatched delegate.
- RelayGroupTopBar: admin-only "Delete channel" / "Delete group" item
  (error color), gated on RelayGroupMembership.ADMIN — the same
  authorization as Edit — behind a confirmation dialog that pops back
  off the deleted channel's screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfd77tUsZ8h6ywcgwY7VVB
2026-07-28 20:17:43 +00:00
Vitor PamplonaandGitHub af04267f9a Merge pull request #3778 from vitorpamplona/claude/back-arrow-responsiveness-sz40m9
Fix back arrow visibility during predictive back-swipe animations
2026-07-28 16:09:35 -04:00
Claude 88ee46df05 fix: keep back arrow visible until the exiting screen finishes leaving
canPop() read the globally-current back-stack entry via
currentBackStackEntryAsState(). A pop commits the instant it is accepted
— most visibly during a predictive back-swipe, whose exit animation is
long and finger-driven — so controller.currentBackStackEntry flips to the
destination while the screen being dismissed is still on screen, sliding
out and still composing its top bar. Evaluating canPop against the
incoming destination there dropped the back arrow (and re-showed the
bottom bar) before the outgoing screen had finished leaving, which is
exactly the flicker seen when back-swiping to Home or a bottom-nav root.

Evaluate poppability against the screen's own NavBackStackEntry — the one
the NavHost provides to each destination via LocalViewModelStoreOwner —
which is intrinsic to that screen and never changes for the life of its
composition. The arrow now stays put until the screen itself is gone.
Callers outside a NavHost destination (shell chrome, drawer) see the
account-scoped owner instead of an entry and fall back to the previous
globally-current behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011matx1mGJ6ZyS5dS77EQUo
2026-07-28 20:03:43 +00:00
Vitor PamplonaandGitHub ab5a59a106 Merge pull request #3776 from vitorpamplona/claude/amethyst-lint-translation-hvu5pz
Remove unused buzz_dm_hide string resource
2026-07-28 15:55:03 -04:00
Claude c49e2d3390 fix: remove orphaned buzz_dm_hide translations
The `buzz_dm_hide` string key exists only in translation files but has no
entry in the default `values/strings.xml` and is not referenced anywhere in
code. This triggered the lint ExtraTranslation error and failed the
`:amethyst:lintFdroidBenchmark` build. Removed the orphaned key from all 7
locale files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ksnCb8x45HnL7nFPzxh4B
2026-07-28 19:45:48 +00:00
Vitor PamplonaandGitHub f3db2c73dc Merge pull request #3773 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 15:41:40 -04:00
vitorpamplonaandgithub-actions[bot] 456684edb1 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 19:22:32 +00:00
Vitor PamplonaandGitHub 31582320f2 Merge pull request #3775 from vitorpamplona/claude/resource-consumption-trigger-709n5v
Relax resource usage alert thresholds
2026-07-28 15:19:30 -04:00
Claude 5cb84e16dc test: cover the version-note guard path in observeNotes dedup
Add coverage for a real consumeBaseReplaceable call the suite was missing:
observers are also notified with the "version" note (getOrCreateNote(event.id),
a regular Note carrying the AddressableEvent), which the addressable-list guard
must drop while still listing the AddressableNote for the same event.

Confirmed the concurrency the stress tests exercise is real, not theoretical:
relay events are verified+consumed inline on per-relay socket dispatchers, so
distinct relays drive new()/remove() on the same note instance concurrently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 19:17:57 +00:00
Vitor PamplonaandGitHub 68664bfac1 Merge pull request #3774 from vitorpamplona/claude/buzz-community-channel-style-l2l63v
Simplify ConcordAuthorFacepile to use ClickableUserPicture
2026-07-28 15:10:44 -04:00
Claude d9d02110dd fix(resource-usage): raise the background mobile data trigger to 500 MB
Bump BG_MOBILE_BYTES_PER_DAY from 100 MB to 500 MB/day so the report
prompt only flags genuinely excessive background cellular traffic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2efhyiQHKFoNjQo6WnGRH
2026-07-28 19:10:43 +00:00
Claude 8a41129dfc fix(resource-usage): double the report-prompt trigger thresholds
Raise every ResourceUsageAlerts threshold to 2x so the "this app is
consuming too much" report prompt only fires at twice the previous
consumption levels, cutting false positives on heavy-but-normal days:

- background mobile data: 50 MB -> 100 MB / day
- background mobile relay-connection time: 12 h -> 24 h / day
- notification wakelock: 30 min -> 60 min / day
- app process starts: 75 -> 150 / day
- completed relay (re)connections: 5000 -> 10000 / day

Tests reference the constants symbolically, so the alert suite stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2efhyiQHKFoNjQo6WnGRH
2026-07-28 18:57:30 +00:00
Claude baf5f37532 feat: render Buzz channel recent-poster facepile with the standard avatar
The recent-posters facepile in a Buzz community's channel rows drew each
poster with ObserveAndDrawInnerUserPicture — a bare cropped image wrapped
in a surface-coloured ring, overlapped into a deck. That omitted the
following badge and trust-score tag every other user avatar in the app
carries.

Draw each poster with ClickableUserPicture (the regular BaseUserPicture
path) instead, so the facepile shows the following icon (top-right) and
trust-score tag (bottom-centre). Lay the avatars out with a small gap
rather than an overlapping stack so those badges stay readable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f3JZdoChYEEDtqTArFAxn
2026-07-28 18:54:23 +00:00
Vitor PamplonaandGitHub 3842ab14bb Merge pull request #3772 from vitorpamplona/claude/concord-buzz-messages-visibility-ye6ocz
Add long-press menu to relay group & Concord channels in Messages
2026-07-28 14:51:15 -04:00
Claude 49e4234f99 feat: align leave vs remove-from-messages actions across chat types
"Leave" meant three different things and the actions were scattered across
each channel's own screen, so they were hard to find from Messages. Make the
vocabulary consistent and reachable:

- "Leave" now always means "renounce membership / you're out" — the kind-9022
  LeaveRequestEvent for NIP-29/Buzz groups, and the kind-13302 self-list removal
  for Concord (its only exit).
- "Remove from Messages" is the single soft action: take it off my list but keep
  membership. For a joined relay group this drops the kind-10009 entry without a
  9022 (I stay in the roster) and dismisses the invite so a Buzz kind-44100
  re-announce can't bounce it back. The Buzz DM "Hide conversation" reuses the
  same label.

Surface both on the Messages rows via long-press (previously only reachable
inside each group/community screen):
- Relay-group row: "Remove from Messages" + "Leave".
- Concord row: "Leave" (reuses the existing confirm dialog; a community has no
  soft/hard split since the list entry is the whole membership).

Split the relay-group top-bar menu into the same two actions, thread an optional
onLongClick through ChannelName, and consolidate the buzz_dm_hide string into the
shared remove_from_messages string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRifAJ75U4zWbg3V3Y3g8g
2026-07-28 18:40:16 +00:00
Vitor PamplonaandGitHub 48eea9a350 Merge pull request #3771 from vitorpamplona/claude/top-nav-font-sizes-vg4l9q
Remove unnecessary FontWeight.Bold styling from Text components
2026-07-28 14:34:12 -04:00
davotoula 7718e3ab19 fix: extract duplicated string literals 2026-07-28 20:29:21 +02:00
Claude 47be9909da fix: normalize bold top nav bar titles to default weight
The Buzz relay group (community) top bar and its sub-screens (members,
threads, browse, channel list), the Buzz canvas, geohash chat/new/teleport
screens, the single-URL and single-geohash viewers, and the Marmot group
chat all forced FontWeight.Bold (geohash chat also titleMedium) on their
top-bar titles, while every sibling channel header — DM rooms, public
chats, ephemeral chats, live activities — and the shared TopBar wrappers
render the Material3 titleLarge default at normal weight.

Drop the bold (and size) overrides so all top nav bar titles share one
consistent treatment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
2026-07-28 18:27:16 +00:00
Claude 636331487a fix: make observeNotes dedup lock-free and race-safe under concurrent consume
Audit follow-up. The previous fix used two independent concurrent structures
(ConcurrentSkipListSet + ConcurrentHashMap) coordinated with putIfAbsent, but
observer callbacks fire from multiple consume threads at once (relay ingest +
UI-side justConsume). A new()/remove() interleaving for the same idHex could
desync the two structures — remove() clears byId and no-ops on the sorted set
before new() has added the entry — leaving an orphan that a later new()
duplicates, reintroducing the duplicate-key crash.

Keep it lock-free (this observer is used everywhere and needs the throughput):
every write to the sorted index for a given idHex now happens inside that key's
ConcurrentHashMap.compute critical section, so the sorted set and membership map
move together. ConcurrentHashMap stripes per key, so same-idHex ops serialize
while different keys stay fully parallel. Invariant: an entry is added to the
sorted set only while its key is absent from byId, and every path that frees a
key removes its sorted entry first, so the set can never hold two entries for
one idHex.

Adds concurrency stress tests (with and without a relay limit) that fan out 8
threads hammering new/remove while created_at churns; both fail against the
non-atomic version and pass here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 18:24:37 +00:00
David KasparandGitHub 153d28b420 Merge pull request #3770 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 20:17:44 +02:00
davotoulaandgithub-actions[bot] 8ce93f3700 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 18:15:15 +00:00
davotoula 14db81177f update cs,pt,de,sv 2026-07-28 20:06:18 +02:00
Claude 65e30c0acd fix: keep observeNotes list sorted while deduping addressables by idHex
Address review feedback: keep the incrementally-maintained, created_at-sorted
structure (a feed must stay sorted like a relay) instead of re-sorting a hash
map on every emission.

The root cause is unchanged: there is one Note instance per id/address
(LocalCache owns creation), but a note's sort key is mutable — a newer
replaceable event swaps the event on the SAME AddressableNote instance,
changing created_at in place. A sorted set ordered on that live value corrupts:
the moved node leaves the add()/remove() search path, so the same instance is
inserted twice and the emitted list carries a duplicate idHex, crashing the
App Recommendations LazyColumn (keyed on idHex).

Fix: snapshot the sort key into an immutable Entry when the note first enters,
order a ConcurrentSkipListSet on that snapshot (never read live again), and
index entries by the stable idHex (ConcurrentHashMap + putIfAbsent) so
membership stays unique and removal is reliable regardless of later created_at
changes. Ordering and "new versions do not update the list" are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 18:03:26 +00:00
Claude d9ae9cbd37 fix: normalize top nav bar title font sizes
Three top nav bar titles overrode the Material3 titleLarge default with
titleMedium, rendering ~16sp while every other top bar title inherits
~22sp. Drop the titleMedium override so the Calendar event detail and Git
repository (home + sub-screen) titles match the rest of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
2026-07-28 17:42:37 +00:00
David KasparandGitHub a585fe3664 Merge pull request #3764 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 19:41:02 +02:00
Vitor PamplonaandGitHub b085c8cc37 Merge pull request #3767 from vitorpamplona/claude/corcord-read-only-communities-gu1x4k
Implement CORD-02 §9 community dissolution seal
2026-07-28 13:40:28 -04:00
vitorpamplonaandgithub-actions[bot] 8145bedd15 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 17:36:46 +00:00
Vitor PamplonaandGitHub 6666637f9c Merge pull request #3766 from vitorpamplona/claude/lazygrid-duplicate-key-v4k7sj
Fix duplicate app entries in Discover Apps grid
2026-07-28 13:34:03 -04:00
Vitor PamplonaandGitHub 6086e48859 Merge pull request #3765 from vitorpamplona/claude/keyboard-ime-stuck-state-et3yfu
Fix keyboard state tracking and back handler races
2026-07-28 13:33:14 -04:00
Claude eeefacca9e fix(ime): union nav-bar and IME insets on the two remaining bare-Scaffold forms
Sweep of every imePadding() in the app for the same nav-bar-over-IME
double-count fixed for the chats. Two more bare Material3 Scaffold screens
applied the scaffold content padding (which carries the nav-bar inset) and
then imePadding() without consuming in between, so the nav bar stacked on top
of the IME while the keyboard was up:

- NewGeohashChatScreen (geohash create form)
- MarmotGroupInfoScreen (has the add-member search field)

Both now consumeWindowInsets(pad) before imePadding(), matching the idiom the
other forms already use. Every other imePadding() call was verified fine:
DisappearingScaffold-based screens are handled by the scaffold's own
reservation, dialogs/bottom sheets carry no nav-bar content padding, and the
rest either apply imePadding() alone at a root or already use the
navigationBarsPadding()/consumeWindowInsets union.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
2026-07-28 17:25:34 +00:00
Claude 0b0abeebbb fix: prevent duplicate LazyColumn key from observeNotes on addressable updates
NoteListMatchingFilter (backing LocalCache.observeNotes) stored notes in a
ConcurrentSkipListSet ordered by CreatedAtIdHexComparator. AddressableNotes are
mutable: when a newer replaceable event arrives, LocalCache swaps the event on
the SAME note instance (consumeBaseReplaceable -> loadEvent), changing its
createdAt in place, then re-notifies observers. A sorted set cannot survive a
member's sort key mutating underneath it — the moved node is no longer found on
the add() search path, so the same note gets inserted a second time and the
emitted list carries a duplicate idHex.

The App Recommendations screen keys its LazyColumn on note.idHex (an
AddressableNote's address, e.g. 31990:<pubkey>:nostr-dvm-labeler), so the
duplicate crashed with IllegalArgumentException: "Key ... was already used".

Dedupe by the immutable idHex instead of a createdAt-ordered set; ordering is
computed fresh on each emission. Adds a regression test reproducing the
multi-item corruption path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 17:23:50 +00:00
Claude a569095e19 feat(concord): honor CORD-02 §9 read-only seal on dissolved communities
A dissolved community (owner-signed kind-3308 tombstone) is sealed
read-only per CORD-02 §9: held keys still open history, but nothing new
is honored. The `dissolved` flag was folded in quartz but ignored by the
write gates, so members — and the CLI — could still post to a dissolved
community.

- commons: ConcordChannel now tracks `dissolved` from the folded state
  and `canPost()` returns false when set, so the Android composer (which
  gates on it) is hidden. The self-delete carve-out is unaffected — it
  runs through the note context menu, not the composer.
- amethyst: show a read-only notice where the composer would be so the
  seal is explained rather than silent.
- cli: `amy concord send` folds the community and refuses with a
  `dissolved` error before building/publishing; `amy concord channels`
  surfaces the `dissolved` flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HateTjrutJ23wAEttEwHQA
2026-07-28 17:22:24 +00:00
Claude 5174c8e5a3 fix: dedupe Discover apps by coordinate to avoid duplicate LazyGrid keys
The Browser home "Discover nsites/napplets" sections render each followed
manifest in a LazyVerticalGrid keyed by "ns:"/"np:" + coordinate. The
observed store (NoteListMatchingFilter, backed by a ConcurrentSkipListSet
ordered by created-at/id) can surface the same addressable note twice when a
replaceable manifest gets a new version — mutating the note's sort key inside
the set breaks dedup. Both copies map to the same coordinate, producing a
duplicate grid key and crashing with IllegalArgumentException.

Collapse the mapped list by coordinate so each app appears once and grid keys
stay unique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9TomvuP9YnPUDCZuiQae6
2026-07-28 17:09:31 +00:00
Claude 39e272f478 fix(ime): stop nav-bar padding from stacking on top of the IME inset in chats
While the keyboard was up, the public / relay-group / live-activity /
ephemeral chats — and the Concord channel + minichat views — left an extra
navigation-bar-height gap between the composer and the keyboard. WindowInsets.ime
is measured from the bottom of the screen, so it already spans the nav-bar
band; reserving the nav bar again on top of it double-counts. NIP-17 DMs were
unaffected because their scaffold reserves the nav bar with the consuming
navigationBarsPadding(), which excludes the already-consumed IME inset (a
union), while the bottom-bar chats reserved it as a plain, non-excluding pad.

- DisappearingScaffold: when the bottom-bar slot renders empty it now reserves
  max(0, navigationBars - ime) instead of the full nav-bar inset, so the
  reservation drops to zero while the keyboard is up (the root imePadding has
  already lifted the scaffold above it). Covers every bottom-bar chat at once.
- ConcordChannelScreen / MinichatScreen: use the standard
  padding(padding).consumeWindowInsets(padding).imePadding() union instead of
  summing a plain padding(padding) with imePadding().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
2026-07-28 17:05:57 +00:00
Claude f734865a31 fix(ime): stop the soft keyboard state from getting stuck open
Leaving a chat with the keyboard up via a back gesture left a large IME
padding stranded at the bottom — and, because WindowInsets.ime is a single
app-wide holder, on every other screen too — until some later inset pass
happened to rebalance it. It only reproduced on release builds.

Root cause: the chat composers install a BackHandler that flushes the draft
and pops the screen. When that pop runs while the keyboard is still visible,
the predictive-back window animation races the IME's close animation. On an
optimized release build the window animation wins, and the IME
WindowInsetsAnimation is cancelled before its terminal (zero) frame reaches
Compose, so the shared insets holder stays "animating" and every
imePadding() in the app freezes at the keyboard height. Debug and benchmark
builds are slow enough that the IME animation completes first, which is why
they were unaffected.

Fixes:
- New KeyboardAwareBackHandler: while the keyboard is on screen it does not
  consume back, so the system dismisses the keyboard first (its animation
  completes cleanly); the next back runs the original handler. The top-bar
  back arrow remains an always-available exit. Adopted in the DM, public-chat
  and new-group-DM composers.
- Rederive keyboardAsState() from WindowInsets.ime instead of the
  pre-edge-to-edge getWindowVisibleDisplayFrame/OnGlobalLayout heuristic,
  which under enableEdgeToEdge() could itself latch Opened after the keyboard
  closed. Now it tracks the same inset that drives imePadding().
- Concord channel chat and the minichat thread view used a bare Material3
  Scaffold whose content insets ignore the IME; add imePadding() so the
  composer rides above the keyboard while typing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
2026-07-28 16:29:32 +00:00
Vitor PamplonaandGitHub 84a96f1f86 Merge pull request #3763 from vitorpamplona/claude/fix-localcache-dropdown-warnings-y4952o
Refactor parameter names for clarity and update Material3 API
2026-07-28 11:50:54 -04:00
Claude 3ffa259cff fix: resolve LocalCache override, dropdown deprecation, and shadowed extension warnings
- Rename LocalCache Dao overrides to match supertype parameter names
  (getOrCreateUser: pubkey->hex, getOrCreateNote: idHex->hex,
  getOrCreateAddressableNote: key->address) so named-argument calls stay safe.
- Replace deprecated MenuAnchorType with ExposedDropdownMenuAnchorType in the
  Buzz dropdown composables.
- Remove the buzzChannelType() extension in BuzzChannelMetadata, now shadowed by
  the equivalent (stricter) member on GroupMetadataEvent, and drop its unused
  imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VBP6HGj9M2kzA4WKAivjC
2026-07-28 15:37:01 +00:00
Vitor Pamplona 775c76621a v1.13.0 2026-07-28 10:52:38 -04:00
Vitor Pamplona 9497510733 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-07-28 10:46:18 -04:00
Vitor Pamplona 5424a62f93 Fixes the order of settings 2026-07-28 10:41:49 -04:00
David KasparandGitHub 2cbf57fe79 Merge pull request #3761 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 16:41:30 +02:00
vitorpamplonaandgithub-actions[bot] b8a9c0242c chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 14:38:25 +00:00
Vitor PamplonaandGitHub 414d3fdda0 Merge pull request #3762 from vitorpamplona/claude/bottom-nav-settings-move-fsytta
Reorganize settings catalog: move UI settings to dedicated section
2026-07-28 10:35:31 -04:00
Claude a0202618c1 refactor: move per-account setting entries into Account Settings
Move the bottom navigation bar entry out of App Settings and into the
Account Settings section of the All Settings catalog, since its state is
stored per account (AccountNavigationPreferencesInternal.bottomBarItems in
the account-synced settings blob).

While auditing the App Settings section for other per-account entries, two
more were purely per-account and moved alongside it:

- Reactions settings (reaction row actions) writes account-synced
  reactionRowItems and now sits next to the existing "Reactions" entry.
- Messages settings writes account-scoped chat feed toggles and view modes
  (account.settings.enabledChatFeeds / relayGroupViewMode / concordViewMode).

The remaining App Settings entries stay put: UI preferences, Home tabs,
Profile UI, Calendar reminder, OTS, Namecoin and Resource usage are all
device-global, and Compose/Notification settings are mixed (they hold
genuine app-global preferences alongside a few per-account toggles).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH88gCbbdTfdzp9wrr7buJ
2026-07-28 14:19:19 +00:00
Vitor PamplonaandGitHub 26a6cd37fb Merge pull request #3759 from davotoula/fix/playback-error-overlay-starves-browser-button
Keep the "open in browser" button alive in a short error box
2026-07-28 09:12:59 -04:00
David KasparandGitHub 93fd107ecf Merge pull request #3760 from davotoula/fix/workout-combined-sessions-plurals
Make the combined-sessions count a plurals resource
2026-07-28 10:33:11 +02:00
davotoula 416ac20f30 fix(workouts): make the combined-sessions count a plurals resource 2026-07-28 10:28:48 +02:00
David KasparandGitHub 58c88797f1 Merge pull request #3758 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 10:26:17 +02:00
davotoulaandgithub-actions[bot] e149f935e1 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 08:08:23 +00:00
davotoula c89382f767 update cs,sv,de,pt 2026-07-28 09:54:50 +02:00
davotoula 0b1c4894a0 Code review:
- scale the icon threshold so it can't slice the title
- make the button's survival structural, not threshold-tuned
2026-07-28 09:47:11 +02:00
davotoula 86d0434628 fix(video): keep the "open in browser" button alive in a short error box 2026-07-28 09:46:45 +02:00
Vitor PamplonaandGitHub 011d5f2fa5 Merge pull request #3757 from vitorpamplona/claude/nip84-highlight-marker
feat(highlights): render NIP-84 quotes with a highlighter-pen marker
2026-07-27 22:48:01 -04:00
Vitor PamplonaandClaude Opus 5 7d13f373ac feat(highlights): keep auto-translation on the marked quote
Replacing the markdown pipeline dropped TranslatableRichTextViewer, and
with it the ML Kit auto-translation of the quoted passage. Restore it
without giving up the marker.

A translation rewrites the passage, so the character offsets that locate
the quote inside its context stop pointing at anything. Translate the
quote as well and re-find it in the translated context: ML Kit works
sentence by sentence, so a quote that is one or more whole sentences --
the common case, since people highlight sentences -- comes back identical
whether it is translated alone or inside its paragraph.

  untranslated             -> context, original span marked
  translated + quote found -> translated context, translated span marked
  translated + not found   -> translated quote alone, fully marked

The fallback is free: HighlightQuote.of already returns the quote alone
when the needle is not in the haystack, so the not-found case needs no
special casing. Marking a guessed span, or claiming the whole paragraph
was highlighted, would both be worse than showing less.

That second translation must not draw its own status bar, so add
rememberTranslation() to both flavors: play reuses the existing
translateAndCache and its cache; fdroid, which ships no translation
service, returns the content unchanged.

Also indent the "Auto-translated from X to Y" line to the quote's own
15dp so it lines up with the text rather than the bar.

Verified on device: an English highlight renders as a fully translated
Portuguese paragraph with the quoted sentence marked inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:39:57 -04:00
Vitor PamplonaandClaude Opus 5 057623e3ab feat(highlights): render NIP-84 quotes with a highlighter-pen marker
The renderer never drew a highlight. It synthesised a markdown string --
blockquote each line with "> ", wrap the quoted span in "**" -- and handed
it to the rich-text viewer, so a highlight arrived as bold text. That also
meant the quoted article prose was parsed as markdown, so any *, _, # or [
in it was interpreted as formatting rather than shown.

Drop the markdown round-trip and paint the marker behind the glyphs. The
stroke is drawn per visual line from the TextLayoutResult, so it follows
soft wraps and stops at real glyph edges. Per-line rounded rects rather
than SpanStyle(background), which can only ever be a hard full-line-height
rectangle -- that is what buys the rounded pen ends.

Size the stroke from the baseline and font size, not the line box, so
leading and stroke weight stay independent knobs.

Along the way:

- Locate the quote as an index range instead of context.replace(), which
  marked every occurrence when a quote repeated. Use the W3C
  TextQuoteSelector prefix -- already on the event, previously ignored --
  to disambiguate.
- Restore 1.35em leading. The markdown path forced 1.5em via
  MarkdownTextStyle; the ambient bodyLarge sets no lineHeight at all, so
  rendering plain text inherited the font's intrinsic ~1.2em.
- Indent the source attribution by the quote's own 15.dp so it lines up
  with the text rather than the bar, and space the comment, quote and
  attribution 8.dp apart -- they were flush at 0.dp.
- Clamp the stroke to the column so it cannot be clipped on full-width
  lines.

Light keeps a near-opaque yellow with dark glyphs reading through it. Dark
cannot do that, so it gets a translucent amber that glows rather than
covers. Not derived from the user's accent: a highlighter reads as yellow.

The quoted passage no longer routes through TranslatableRichTextViewer, so
it loses its auto-translate affordance; drawing the marker requires owning
the text layout. The author's own comment above the quote keeps it.

Verified on device in both themes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:03:28 -04:00
Vitor PamplonaandGitHub 329bfe95e7 Merge pull request #3752 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-27 21:40:02 -04:00
vitorpamplonaandgithub-actions[bot] 99f0443a98 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 01:37:22 +00:00
Vitor PamplonaandGitHub 9f7056165d Merge pull request #3755 from vitorpamplona/claude/bottom-nav-per-user-s5h6p4
Move bottom-bar persistence from app-global to per-account synced settings
2026-07-27 21:34:48 -04:00
Vitor PamplonaandGitHub 918cec6bf8 Merge pull request #3756 from vitorpamplona/claude/notification-settings-account-display-oz5ujd
Display user profiles in notification settings account list
2026-07-27 21:34:26 -04:00
Vitor PamplonaandGitHub d62a94f2eb Merge pull request #3753 from vitorpamplona/claude/changelog-1.13-audit
docs(changelog): tighten v1.13.0 highlights and audit against commits since v1.12.6
2026-07-27 20:02:46 -04:00
Claude 586346a68f fix: keep bottom-bar edits ordered to prevent a stale revert
Persisting a bottom-bar edit went through `launchSigner { account.change... }`,
which dispatches on a multi-threaded pool. Because the reactive StateFlow emit
happened inside that coroutine, two quick edits (e.g. tapping Add on several
catalog rows) could complete out of order: the older list would win the flow,
and the settings screen — which re-seeds its editable list from the flow via
`LaunchedEffect(savedItems) { syncFrom(...) }` — would visibly revert the newer
edit and publish the stale list in the NIP-78 event.

Apply the change to the in-memory flow synchronously on the caller (UI) thread
via `Account.applyBottomBarItems` (a non-suspending emit + local save, both
non-blocking) so rapid edits stay strictly ordered, and run only the
sign/encrypt/publish off-thread. Restores the ordering guarantee the previous
synchronous `tryEmit` had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP
2026-07-27 23:58:13 +00:00
Claude 39c05fb538 refactor: use shared UserPicture/UsernameDisplay in notification settings
Replace the hand-rolled RobohashFallbackAsyncImage + observeUserInfo +
CreateTextWithEmoji in the background-accounts list with the app's standard
user components: LoadUser resolves the User behind each npub, and
ClickableUserPicture / UsernameDisplay render the picture and name. This
reuses the shared metadata observation, robohash fallback, custom-emoji
rendering, per-user nickname handling, and npub fallback instead of
duplicating that logic here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvB5t1ittHpw4PdJQYZYfp
2026-07-27 23:55:13 +00:00
Claude 7a41e21272 feat: make bottom navigation bar per-account via NIP-78
The bottom nav row configuration was an app-global setting stored in the
shared DataStore, so every account shared one bar. Move it into the
per-user NIP-78 app-specific data event (AppSpecificDataEvent) so each
account keeps its own bar and it syncs across the user's devices.

- Add `navigation.bottomBarItems` to AccountSyncedSettingsInternal (the
  serialized/encrypted synced-settings blob) and mirror it as a StateFlow
  in AccountSyncedSettings (seed / toInternal / updateFrom).
- Add AccountSettings.changeBottomBarItems, Account.changeBottomBarItems
  (republishes the NIP-78 event), and AccountViewModel.changeBottomBarItems
  / bottomBarItemsFlow().
- Remove bottomBarItems from the app-global UiSettings / UiSettingsFlow /
  UiSharedPreferences (including the now-unused encode/decode migration
  helpers).
- Point the live bar, navigation rail, preloaders, subscriptions and the
  Bottom Bar settings screen at the per-account flow.

No migration from the previous app-global setting: accounts start from the
default bar, matching the requested behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP
2026-07-27 23:30:17 +00:00
Vitor PamplonaandGitHub a9b22a927f Merge pull request #3754 from vitorpamplona/claude/finduserstest-metadata-failure-24a9ck
Fix flaky FindUsersTest by retaining users in cache
2026-07-27 19:28:27 -04:00
Claude 5be70e3a86 feat: show account picture and name in notification settings
The background-accounts list in the Notification Settings screen showed
each account by its npub only. Resolve the User behind each account and
observe its live metadata so the row displays the profile picture and
best display name (with the npub as a secondary line), falling back to
the short npub while metadata loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvB5t1ittHpw4PdJQYZYfp
2026-07-27 23:23:20 +00:00
Vitor Pamplona 70646f4609 adds gigi to changelog 2026-07-27 19:05:55 -04:00
Vitor Pamplona 8a587da45a updates dependencies 2026-07-27 19:05:45 -04:00
Claude 2d0ccb4d70 fix: stop FindUsersTest flaking when GC evicts weakly-cached users
DesktopLocalCache stores users in LargeSoftCache, which holds each User
via a WeakReference. FindUsersTest populated the cache but kept no strong
reference to the created users, so a GC between consumeMetadata and
findUsersStartingWith could collect them — LargeSoftCache.forEach then
skips (and evicts) the cleared entries, making the search return fewer
results. multipleUsersWithMetadata allocates the most objects and tripped
this most often (AssertionError at FindUsersTest.kt:115).

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4BThbs8tz9egmFDYv2RPB
2026-07-27 23:04:04 +00:00
Vitor PamplonaandClaude Opus 5 f55b3cb6d9 docs(changelog): tighten v1.13.0 highlights and audit against commits
Highlights carried full feature descriptions that the sections below
already repeated. Reduce each to a single line and fold the detail down
into the section that owns it.

Audit the notes against all 1961 non-merge commits since v1.12.6:

- Drop the claim that WebSocket frame dispatch moved to a dedicated pool.
  It landed in a5c2a8b2c1's parent and was reverted 23 minutes later
  because the pool regressed. Replace it with the two receive-path wins
  that did ship (CachingEventDecoder, ParallelEventVerifier).
- Add the chat redesign, which had no section at all: bubbles,
  swipe-to-reply, name colors, jumbo emoji, day headers, the two-stage
  long-press sheet, and the new-conversation chooser.
- Add in-app podcast authoring, which the Podcasts section omitted in
  favour of consumption only.
- Promote the resource-usage ledger out of a single Wallet line into its
  own section, alongside the background-service master switch and
  memory-pressure trimming.
- Add large-screen support, NIP-85 nicknames, Birdstar and PS1 cards,
  compose signature, Marmot group icons, and other unreferenced work.

Remove the Upgrading section. No prior release has one, and all three of
its items were consequences of features documented further down, so they
now sit with the feature that causes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:03:09 -04:00
Vitor PamplonaandGitHub 2beb75b59e Merge pull request #3751 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-27 18:23:37 -04:00
vitorpamplonaandgithub-actions[bot] a4ec34a96d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-27 21:13:25 +00:00
Vitor PamplonaandGitHub 36792d09b1 Merge pull request #3750 from vitorpamplona/claude/update-changelog-3mwv4t
docs: update v1.13.00 changelog with new features
2026-07-27 17:10:10 -04:00
Claude 9df51641ea docs(changelog): split v1.13.0 fold-in notes into shorter sentences
Break the newly added BOLT12, Buzz Agent Work board, Blossom, Cashu,
NWC, search-indexing, and geode bullets into short verb-first sentences,
removing em-dash run-ons to match the changelog house style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hmbpUJPxrsN4m85Kwemcw
2026-07-27 21:06:24 +00:00
Claude 6c1412b7c9 docs(changelog): fold post-2026-07-24 work into v1.13.0 notes
Adds the BOLT12 payments & zaps (NIP-B1) work, the Buzz Agent Work
board / workflow runner, Concord message editing, the Blossom gallery
+ importer, Cashu P2PK-token claiming, NWC NIP-44/deep-link/notify
improvements, wider NIP-50 search indexing, and the geode release
pipeline + pluggable event store, plus assorted fixes. Credits dergigi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hmbpUJPxrsN4m85Kwemcw
2026-07-27 20:55:39 +00:00
Vitor PamplonaandGitHub 37759cf517 Merge pull request #3744 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-27 16:45:55 -04:00
vitorpamplonaandgithub-actions[bot] 1c581eb07a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-27 20:22:29 +00:00
Vitor PamplonaandGitHub 0b73236e76 Merge pull request #3749 from vitorpamplona/claude/buzz-event-p-tags-7ud8bj
Resolve @mentions in message bodies to p-tags across all chat types
2026-07-27 16:19:46 -04:00
Claude 89add9d053 refactor(chat): require an explicit Dao on NewMessageTagger
Drop the `dao = LocalCache` default; the two callers without an
AccountViewModel now pass `dao = LocalCache` explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
2026-07-27 20:17:07 +00:00
Claude 8283b22c2f refactor(chat): let LocalCache implement Dao directly
LocalCache already has getOrCreateUser/getOrCreateNote/getOrCreateAddressableNote,
so make it implement Dao and default NewMessageTagger's `dao` to LocalCache
itself — no wrapper object, no interface-default methods. Dao drops the
`suspend` modifiers (LocalCache's lookups are synchronous) so the object
satisfies the interface; AccountViewModel's delegating overrides follow suit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
2026-07-27 20:13:05 +00:00
Claude 3562a62e0a refactor(chat): keep Dao abstract, implement LocalCache lookups in impls
Move the LocalCache calls off the Dao interface (back to a pure abstract
contract) and into the implementations: AccountViewModel keeps its own
overrides, and the default Dao used by callers without a ViewModel is an
anonymous implementation on NewMessageTagger's `dao` parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
2026-07-27 20:04:25 +00:00
Claude b4da12300e refactor(chat): fold LocalCache Dao defaults into the Dao interface
Replace the standalone LocalCacheDao object with default method
implementations on the Dao interface itself, backed by LocalCache. The
NewMessageTagger `dao` parameter now defaults to a bare `object : Dao {}`,
so callers with no AccountViewModel (model-layer sends, the notification
receiver) just omit it, while UI callers keep passing their AccountViewModel
(which resolves to the same LocalCache calls).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
2026-07-27 19:57:09 +00:00
Claude d305c03e54 feat(chat): resolve mentions in minichat and notification quick-replies
The lightweight reply paths built their events from raw text, so a person
cited with `@name`/`nostr:` was neither resolved into a `nostr:` reference
nor tagged with `p`. Run NewMessageTagger on these paths too, backed by a
new LocalCache-based Dao so they work without an AccountViewModel:

- New `LocalCacheDao`: a `Dao` delegating to `LocalCache`, for mention
  resolution outside a ViewModel (model-layer sends, background receivers).
- `Account.sendMinichatReply` (kinds 9 + 1111): resolve mentions and emit
  `p` tags for cited users across the public-chat, Buzz, and NIP-29 group
  branches (parent author excluded to avoid a duplicate).
- `NotificationReplyReceiver.sendPublicReply` (kinds 1 + 1111): same
  resolution for the system notification quick-reply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
2026-07-27 19:46:00 +00:00
Claude 11147aac10 fix(chat): add p tags for people cited in kind 9/11/1111 messages
Extends the mention p-tag fix to the shared chat/thread composers, which
resolved `@`/`nostr:` mentions via NewMessageTagger but then dropped the
cited users from the signed event:

- kind 9 (ChatEvent, NIP-29 group chat): the reply path tagged only the
  parent author and the plain build path tagged no one. Emit `p` tags for
  every cited body user (reply path keeps the parent's relay-hinted tag and
  excludes it from the extra set to avoid a duplicate).
- kind 1111 (CommentEvent) minichat reply: replyBuilder auto-tags the
  parent/root author but body mentions were dropped. Notify the other
  cited users (parent excluded to avoid a duplicate).
- kind 11 (ThreadEvent, NIP-29 group thread): the ShortNote thread branch
  added no `p` tags at all; emit them from the cited body users, matching
  the sibling Poll/ZapPoll branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
2026-07-27 19:36:57 +00:00
Vitor PamplonaandGitHub 4179a6312a Merge pull request #3747 from vitorpamplona/claude/quartz-negentropy-stalls-6g9ukr
Fix NIP-77 negentropy stall against relays that refuse via NOTICE
2026-07-27 15:28:33 -04:00
Claude 9de8eae5fd test: broad live NIP-77 validation across 30 public relays
Adds NegentropyMultiRelayLiveTest (gated NEG_MULTI=1): runs negentropySyncOrFetch
against 30 reachable public relays and asserts none hangs. Live run: 0 hangs,
0 errors — 10 reconciled via native negentropy, 20 fell over to paging, across
9+ relay softwares (strfry, ditto, purplepag.es, nostr.wine, nostr-rs-relay,
NFDB, rockstr, wot-relay, nostrcheck).

Confirms both fallback paths: the NOTICE fast-path (~1-4s) for relays whose
refusal names negentropy / unknown-envelope, and the idle-watchdog backstop
(~20s) for the rest (e.g. damus silently ignores NEG-OPEN, snort answers
"Unknown message type: NEG-OPEN") — now reliable because NOTICE/CLOSED no longer
reset the watchdog. Matrix recorded in the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
2026-07-27 19:23:46 +00:00
Vitor PamplonaandGitHub b5e5d0c4e7 Merge pull request #3748 from vitorpamplona/claude/buzz-amethyst-support-nfxog7
Add Buzz job/workflow boards and CLI agent/workflow commands
2026-07-27 15:20:16 -04:00
Claude 30b9e589d4 fix: harden negentropy window-splitting and error classification
Audit follow-ups on the NIP-77 client, found while reviewing the notice-rejection fix:

- isOverflow was too broad. A bare "too many"/"too large" match meant a
  NON-shrinking error ("too many requests", "too many concurrent subscriptions")
  was read as a set-too-large overflow. Such an error doesn't shrink with the
  window, so every created_at split re-triggers it and reconcileWindows walks
  toward 1-second leaves, queueing up to ~2^31 Filters (OOM + relay hammering).
  Tightened to result-set-qualified phrases (too many records / too many query
  results / result set too large / max_sync_events); rate/quota errors now fail
  over to paging.

- Added a MAX_WINDOWS (100k) backstop in reconcileWindows: a wording-independent
  guard that bails to paging if a split ever fails to converge, so no novel
  overflow-looking-but-non-shrinking error can storm.

- Window split dropped future-dated events. On overflow the upper child was
  copy(until = hi) with hi = until ?: now(), so once any split happened, events
  with created_at > now() (clock skew) were excluded though the un-split path
  included them. The upper child now keeps the window's original until (may be
  null = unbounded); the split math still uses now() so it converges.

- Hardened the NOTICE rejection matcher. isNegentropyRejectionNotice matched
  bare "envelope"/"NEG-OPEN"/"NEG-MSG"; since a NOTICE has no subId and every
  connection listener sees it, an unrelated notice on a shared connection could
  abort a healthy reconcile mid-handshake. Narrowed to "negentropy"/"unknown
  envelope"; the now-un-defeated idle watchdog is the wording-independent backstop.

- NegentropyStoreSync up-direction memory: haveBatches was an UNLIMITED channel
  drained by a single network-bound uploader, so a first push of a large store
  buffered O(local-set) ids. Bounded it like needBatches to back-pressure the
  reconcile.

- Docs: flagged negentropySyncOrFetch's O(delivered) cross-phase dedup memory
  (steer bulk mirrors to negentropySync/negentropyReconcile); corrected the
  stale onEvent "reader thread" note (it runs on the delivery consumer).

Tests: NegentropyErrorClassificationTest pins both wording classifiers;
NegentropyRejectionFallbackTest adds a rate-limit NEG-ERR case asserting paging
after exactly one NEG-OPEN per phase (no split storm).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
2026-07-27 19:13:14 +00:00
Claude 1d44563d37 fix(buzz): add p tags for people cited in messages
The Buzz composers dropped `p` mention tags for people named in a
message body, so a member cited with `@name` was neither notified nor
linkable and the relay couldn't resolve the `nostr:` reference:

- Stream chat (kind 40002): only the reply-parent author got a `p` tag;
  body mentions from the tagger were ignored. Emit `p` tags for every
  cited user (dedup covers the reply author).
- Stream edit (kind 40003): carried no mentions at all — a mention added
  by an edit lost its `p` tag. Emit them from the edited text.
- Forum reply (kind 45003): never ran NewMessageTagger, so `@`-mentions
  stayed literal and only the reply-parent author was tagged. Run the
  tagger to resolve mentions and emit their `p` tags.
- Forum post (kind 45001): the root composer built the event from raw
  text with no mentions. Run the tagger there too.

Mirrors the mention p-tag handling every other chat kind already does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
2026-07-27 19:09:30 +00:00
Claude 264866bcbb fix: detect NIP-77 negentropy refusal sent as a connection-level NOTICE
Relays that advertise NIP-77 in NIP-11 but refuse it at runtime answer a
NEG-OPEN with a connection-level NOTICE (which carries no subId) instead of a
subId-addressed NEG-ERR:

  - strfry with negentropy disabled: "ERROR: bad msg: negentropy disabled"
  - purplepag.es (no NEG envelope):  "failed to parse envelope: unknown envelope label"

reconcileStreaming only routed NegMsg/NegErr for its exact subId into the driver
channel, so the NOTICE was dropped and the driver blocked in receiveWithinIdle
with no terminating frame. Worse, the connection-level idle watchdog was bumped
by every relay message, so unrelated refusal chatter (a rejected keep-alive REQ
being re-CLOSED on re-sync) reset it forever and it never fired. Net effect:
negentropySync/negentropyReconcile hung against relay.primal.net and
purplepag.es, and negentropySyncOrFetch never reached its paging fallback.

Fix, in reconcileStreaming's connection listener:
  - route a CLOSED for our NEG subId into the driver as a terminal failure;
  - treat a negentropy-refusal NOTICE as terminal, bound to this session by
    phase (before the first valid NEG frame) + wording (isNegentropyRejectionNotice),
    so an unrelated NOTICE on a healthy relay mid-reconcile cannot abort a
    progressing sync;
  - stop bumping the idle clock on NOTICE/CLOSED so refusal chatter can no longer
    keep a dead sync alive; it now advances only on real progress (this session's
    NEG frames and the download REQs' events/EOSEs).

The refusal now surfaces as NegentropySyncException(UNAVAILABLE); negentropySync
throws promptly and negentropySyncOrFetch pages the same filter (both relays
answer ordinary REQs fine). Verified live: primal 0-event hang -> pages 11.7k
events in 6.4s; purplepag.es 0-event hang -> pages continuously.

Tests:
  - NegentropyRejectionFallbackTest: offline, deterministic; a scripted fake
    relay answers NEG-OPEN with each observed NOTICE and asserts the sync throws
    UNAVAILABLE fast and negentropySyncOrFetch sets pagedFallback.
  - NegentropyStallRepro: gated live repro against the three real relays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
2026-07-27 18:12:01 +00:00
Claude b30d39e627 Merge remote-tracking branch 'origin/main' into claude/buzz-amethyst-support-nfxog7
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-07-27 17:58:02 +00:00
Vitor PamplonaandGitHub b5ad53f668 Merge pull request #3746 from vitorpamplona/claude/health-connect-activity-consolidation-y8ao9e
Merge close workout sessions to reduce noise in Health Connect suggestions
2026-07-27 13:50:40 -04:00
Vitor PamplonaandGitHub 855435fcc1 Merge pull request #3742 from vitorpamplona/fix/buzz-create-channel
fix(buzz): make channel create, add-member and role changes actually work
2026-07-27 13:16:32 -04:00
Vitor PamplonaandClaude Opus 5 07abf5e537 fix(buzz): stream group state instead of refetching it
The previous two commits refetched a group's 39000-39003 whenever the relay
narrated a change, on the premise that Buzz could not stream them at all: it
signs them with `d`/`p` tags and no `h`, so an `#h` filter looked unable to match
and a filter without `#h` registers as a global subscription, which never
receives a channel-scoped event.

The first half of that was wrong. `filter_match_one` has an explicit fallback:
for an `#h` filter, when the event carries no `h` tag at all, it matches against
the stored `channel_id` — and these are stored channel-scoped. So an `#h` filter
both indexes the subscription under the channel (which is what makes it eligible
for the channel fan-out) and matches the events when they arrive.

So subscribe, like the rest of the app does. The per-channel `#h` subscription
that already keeps each joined group's chat live now carries its state kinds too,
and every screen updates through the flows it already observes. The fetch, the
event-bundle hook that triggered it and the cache lookup it needed are all gone.

Buzz-only: on a relay29-family relay these events are addressable with no
`channel_id` behind them, so an `#h` filter matches nothing there and the `#d`
directory filters keep serving them.

Verified on emulator-5554 with no fetch code left in the tree: promoting shows
the `admin` badge on the open members screen within seconds, and removing the
role clears it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 12:47:06 -04:00
Vitor PamplonaandClaude Opus 5 aeaacfb1e5 refactor(buzz): make the group-state refresh app-wide, not one screen's job
The previous commit hung the refresh off the members screen, so only that screen
recovered from a stale roster: a rename, a visibility flip or a join seen from the
chat, the channel list or a Messages row stayed wrong until the next cold start.
The rest of the app is reactive — relay to LocalCache to screen — and this should
be too.

Move it into AccountViewModel's always-on event collector: any kind-40099 that
lands names its channel, so re-read that group's 39000-39003 into LocalCache and
every screen observing the group updates through the flows it already has. One
rule, no screen has to know about it.

Two things this had to work around:

- The event names its channel but not its host, and a note's relay list can still
  be empty when the bundle fires. LocalCache.relayGroupChannelsWithId resolves the
  host from the channels already in cache.
- Invalidating the standing state subscription does nothing: its filters are
  unchanged, so no new REQ goes out and no events come back. Measured — the badge
  did not move. It takes an explicit fetch, which is what
  Account.refreshRelayGroupState now does by group id.

Verified on emulator-5554: removing a role updates the badge on the open members
screen with no restart and no screen-local refresh code left in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 12:09:37 -04:00
Vitor PamplonaandGitHub e9074aa2cb Merge pull request #3745 from vitorpamplona/claude/event-searchable-review-swvvqu
feat(quartz): index the public petname on contact cards (30382)
2026-07-27 12:08:29 -04:00
Claude 4c42648c35 feat(quartz): index the public petname on contact cards (30382)
ContactCardEvent.indexableContent() already indexed the public summary
and topics; add the public petName() tag alongside them so a card can be
found by the nickname its author gave the target user. petName()/summary()
read the public tag array, so any petname kept in the NIP-44 encrypted
content stays out of the index (privacy preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
2026-07-27 15:59:54 +00:00
Vitor PamplonaandGitHub cd100e4272 Merge pull request #3743 from vitorpamplona/claude/event-searchable-review-swvvqu
Add SearchableEvent implementation to 18 event types
2026-07-27 11:55:27 -04:00
Vitor Pamplona b1d082159d Merge remote-tracking branch 'upstream/main' into fix/buzz-create-channel 2026-07-27 11:41:51 -04:00
Claude 8e79abaa82 feat(cli): fewer steps to connect a Buzz agent — accept-from-channel, agent up, doctor
Collapses the operator setup from an 8-flag command + two hand-written scripts to
essentially two commands, without removing any of the safety.

- `buzz workflow run` gains `--accept-from-channel` (parity with the job
  scheduler): scope intake to the channel's kind-39002 roster instead of pasting
  every teammate key. `--worktree` now defaults to the current directory.
- Ship the gated reference wrappers (tools/buzz-agent/workflow-agent.sh →
  agent+commit; workflow-ship.sh → push+PR after the gate), split around the
  approval gate the way agent-exec.sh is the one-shot ungated version.
- `buzz agent up RELAY --repo DIR --approver NPUB` — one command: resolves the
  channel (the relay's only one, or --channel), defaults worktree/intake, extracts
  the bundled wrappers to ~/.amy/buzz-agent, and delegates to `workflow run`. The
  only thing it can't default is the human approver.
- `buzz agent doctor [--repo DIR]` — preflight that turns the security checklist
  into a green/red report: gh authenticated, token can write to the repo, default
  branch protected against force-push, worktree clean. Exits non-zero if not.
- cli build: set duplicatesStrategy on processResources (the explicit
  resources.srcDir re-adds the default root, which now doubles the bundled scripts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-27 15:40:50 +00:00
Vitor PamplonaandClaude Opus 5 8bb074c6ae fix(buzz): refresh the roster after a role change, and clear unread on open
**The roster went stale.** Promoting somebody changed nothing on screen until the
next cold start. Buzz signs its 39000-39003 with `d`/`p` tags and **no `h`**, yet
stores and fans them out channel-scoped — so a filter carrying `#h` does not match
their tags, and one without `#h` is a global subscription, which by design receives
no channel-scoped event. Neither shape can be live; measured it directly, a role
change delivers only the kind-40099 that narrates it.

So use that as the cue: the members screen re-reads the group's state whenever a
new system message lands in it, keyed on the message id so it fires once per
change rather than polling. Account.refreshRelayGroupState does the fetch.

**Unread badges never cleared.** loadAndMarkAsRead lived inside NormalChatNote —
the `else` of the render switch — so a row drawn by any specialised path (Buzz
system lines and activity rows, diffs, forum votes, NIP-28 admin lines, zaps)
never advanced the room's last-read marker. On a Buzz relay that is most rows:
joins, adds and role changes are all system messages, so a channel whose newest
events were those kept its badge no matter how often it was opened. Hoisted the
call to cover every row type; NormalChatNote's now-dead routeForLastRead
parameter is gone.

Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting a
member now shows the `admin` badge without restarting the app, and opening
`general` cleared its badge while the channels left untouched kept theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:38:50 -04:00
Claude 052da60f9c feat(quartz): index missing SearchableEvent kinds and tag-borne text
Massive review of every Event subclass in Quartz that carries
human-authored plaintext at rest but was not participating in the NIP-50
full-text index (SearchableEvent).

Add SearchableEvent to 15 kinds whose content is plaintext searchable
text a user would look for:
- 9737 Bolt12 zap intent (comment) — mirrors 9734/9736
- 5302 / 5303 NIP-90 content / people search request (search query)
- 31871 / 31872 attestation / attestation request — sibling family was
  already searchable
- 1315 roadstr road-event report (free-text comment)
- 45001 / 45003 Buzz forum post / comment (body)
- 48106 Buzz huddle guidelines
- 30176 / 30175 / 30177 / 10100 Buzz team / persona / managed-agent /
  agent-profile (name, description, system prompt)
- 30620 Buzz workflow definition (name + YAML)
- 3302 Concord chat edit (replacement message text) — mirrors kind-9 chat

Widen indexableContent() on 8 kinds that already implemented
SearchableEvent but dropped natural-language text carried in tags:
- 30020 auction — category hashtags were written by build() but never
  indexed
- 12473 Birdex — species names
- 30382 contact card — public topics
- 9002 NIP-29 group-metadata edit — hashtags
- 30054 Podcasting-2.0 episode — topics
- 38192 PS1 save — region name
- 1111 comment / 1311 live-activity chat — hashtags

Encrypted-at-rest (NIP-04, giftwraps, MLS/marmot, NWC, cashu), purely
structural (relay/follow lists, reactions, deletions, moderation/presence
signaling), and ephemeral events were reviewed and deliberately left out.
The NIP-31 alt tag was also left out: it is frequently kind-level client
boilerplate and would dilute relevance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
2026-07-27 15:33:11 +00:00
Vitor PamplonaandClaude Opus 5 ceb9f9c3db feat(buzz): dock the member search at the bottom, and fix promotions on Buzz
**Adding people.** The Members screen hid its search behind a FAB, so adding a
handful of people was "open dialog, search, pick, dialog closes, reopen" per
person. The field now lives at the bottom of the screen with its results rising
above it, like a chat composer: each pick lands in the roster above and clears
the query while the keyboard stays up. It clears the gesture bar and rides above
the IME, so the field you type into is not the part that gets covered.

**Promotions did nothing.** "Make moderator" published a kind-9000 and changed
nothing, on either client. Two reasons:

- NIP-29 carries roles inside the `p` tag; Buzz reads a top-level `role` tag
  (`extract_tag_value(event, "role")`) and defaults to `member` without it. So
  every promotion re-added the target as a plain member. PutUserEvent can now
  carry that tag and Account maps our role onto Buzz's vocabulary before sending.
- That vocabulary is `owner`/`admin`/`member`/`guest`/`bot` — there is **no
  moderator**, and a role the relay cannot parse fails the whole put-user. So the
  action is hidden on Buzz rather than offered and silently dropped.

**The owner could not promote anyone.** membershipOf only mapped the literal
`admin` to ADMIN, but a Buzz channel's creator carries `owner` — leaving the one
person with full authority ranked below it, so "Make admin" never appeared. Both
role strings now mean ADMIN.

**The 3-dot button moved when tapped.** An expanded DropdownMenu still emits a
node into its parent, and it sat as a direct child of a `spacedBy(12.dp)` Row —
so opening the menu added a second gap and shoved the button sideways. Button and
menu now share a Box. ConcordMembersScreen had the identical bug and is fixed
too; GitBrowseUi looks like a third instance and is left alone as unrelated
territory.

Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting the
added member published the 9000, the relay narrated it, and after the roster
refreshed the member carries an `admin` badge. The 3-dot sits at the same pixel
column whether the menu is open or closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:14:29 -04:00
Vitor PamplonaandClaude Opus 5 98bda49abb refactor(buzz): give "add people" the app's ordinary user search
Adding someone to a workspace or channel offered a square button and a dialog
whose own hint told you to paste a hex key. Both are now what the rest of the
app does.

The dialog searched **only** LocalCache, so anyone this device had never seen
simply had no result — pasting a raw key was the only way through, which is why
the field said "Add someone (npub or hex)". It now runs on UserSuggestionState,
the same engine behind the @-mention typeahead: local cache, relay search
(NIP-50) and NIP-05 resolution, plus a pasted npub/nprofile. The hint asks for a
name; results show avatar, display name and a verified NIP-05 address.

The members-screen button was the app's only FloatingActionButton without
`shape = CircleShape`, so it rendered as Material3's rounded square next to
circular FABs everywhere else.

Two shared components needed to bend for this, both additive:

- SlimListItem took a `colors` parameter and then painted its container with a
  hardcoded `MaterialTheme.colorScheme.background` regardless — so a row asked
  to be transparent still drew an opaque block. It honours `containerColor` now,
  defaulting to the same `background` it always painted, so every existing
  caller is byte-identical.
- ShowUserSuggestionList's row colours, dividers and top padding are parameters.
  Their defaults are the dropdown's existing look — opaque rows and a divider
  each, which is what separates it from a composer it floats over. Inside a
  dialog that chrome reads as a black box with a gap above it, so this one caller
  passes transparent rows, no dividers and no padding.

Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: searching
"cloudfodder" returns relay results with verified NIP-05s; pasting an npub
resolves to the person; adding them to a channel published the kind-9000, the
relay narrated "Vitor was added by Vitor Pamplona", the member count went 1 → 2,
and that member then posted from Buzz and the message rendered here. The mention
dropdown is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 10:30:51 -04:00
David KasparandGitHub 9153ca4e92 Merge pull request #3740 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-27 16:21:55 +02:00
davotoulaandgithub-actions[bot] 78ad9aaa77 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-27 14:20:46 +00:00
David KasparandGitHub 6d8b2d96e2 Merge pull request #3739 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-27 16:20:09 +02:00
davotoula e09cb521d9 update cs,sv,de,pt 2026-07-27 16:10:48 +02:00
Vitor PamplonaandClaude Opus 5 fa6b272213 fix(buzz): make "create" on a Buzz relay actually create a channel
The community screen's FAB opened the NIP-29 create-group flow, and on a Buzz
relay it published two events and produced nothing: no channel in the list, no
channel anywhere. Two independent reasons, both silent.

**The create was rejected.** NIP-29 puts the id on the kind-9007 and leaves the
metadata to a following 9002. Buzz's ingest.rs validates the 9007 *before
storage* and rejects it with "invalid: channel name is required" unless the
create event itself carries a `name`; it also reads `about`, `visibility` and
`channel_type` off that same event. Our 9007 had only the `h` tag, so it never
stored, and the 9002 behind it addressed a channel that was never made. Send the
metadata on both events: relay29 ignores the extra tags and takes the 9002, Buzz
takes the 9007.

**The id was not a UUID.** Buzz keys channels by UUID and parses the `h` tag with
`val.parse::<Uuid>()`. Our 8-random-bytes hex id doesn't parse, so the relay
discarded it and created the channel under an id of its own — the app then opened
the id *it* had picked, which is why the one channel that did get created showed
a hex title over an empty feed. Generate a v4 UUID when the host speaks Buzz;
NIP-29 ids are opaque strings, so nothing else changes.

The screen matched NIP-29 rather than Buzz, too. It offered a photo, hashtags, a
geohash and four permission flags — of which Buzz's 9002 handler honours exactly
one (`visibility`, two-valued). Those controls looked like they configured the
channel and were dropped on the floor. On a Buzz relay it is now: name,
description, "Private channel" (Buzz's open/private in Buzz's words), and
"Forum channel" for `channel_type` — offered only on create, since Buzz has no
`channel_type` key on edit. Titled "New channel", because Buzz calls them
channels, and the FAB says so. Plain NIP-29 relays are untouched.

Also stops the NIP-11 gate blocking creation on Buzz relays, which advertise no
NIP-29 support yet implement 9007/9002, and makes RelayGroupMetadataViewModel's
`relay` snapshot state — it is assigned after first composition, so a plain var
left the screen stuck rendering its NIP-29 shape.

Verified against nosfabrica.communities.buzz.xyz: creating "amethyst-create-test2"
lands a real channel — right name in the title, Moderator badge, member count 1,
the relay's own "Vitor Pamplona created this channel" system line, and a row in
the channel list. BuzzChannelCreateTest covers both wire-level fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:52:30 -04:00
vitorpamplonaandgithub-actions[bot] 9855abda64 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-27 12:59:14 +00:00
Vitor PamplonaandGitHub 1705963939 Merge pull request #3733 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-27 08:56:20 -04:00
Claude 1b33b1b986 feat(buzz): merge Jobs + Workflow runs into one "Agent work" board (prototype)
Collapses the two near-identical agent backlogs (jobs 43xxx, workflow runs
46xxx) into a single board that speaks one vocabulary, so users stop having to
tell two protocols apart. The human-approval gate is no longer its own screen —
it's the top-sorted, glowing card state (NEEDS_APPROVAL).

- AgentWork.kt: AgentWorkState {NEEDS_APPROVAL,WORKING,QUEUED,SHIPPED,CLOSED} +
  AgentWorkItem, with mappers from WorkflowRun and JobView and a merge() that
  sorts needs-approval first, then working, then the upvote-ranked queue, then
  shipped/closed.
- AgentWorkBoardViewModel: runs both subscriptions (jobs+upvotes, and workflow
  base + by-author decisions) off subscribeAsFlow and folds them via the two
  existing aggregators into one List<AgentWorkItem>.
- AgentWorkBoardScreen: one list; inline approve/deny on the gate card, View PR
  on shipped, upvote/cancel on jobs, and a "New task" sheet whose single
  "Require approval before it ships" toggle picks the protocol — on → a gated
  workflow run, off → a direct job (no workflow definition required).
- Nav: the channel top bar's two board glyphs (Backlog + Workflow runs) collapse
  to one "Agent work" entry (Route.BuzzAgentWork). The standalone JobBoard and
  WorkflowRunBoard screens/routes stay as reference for now.

Prototype scope: strings inline pending adoption; the standalone board's
confirm-before-approve dialog and full localization are the carry-overs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-27 05:01:54 +00:00
Claude a8599ec56b feat(workouts): merge close-by Health Connect sessions of the same type
Watches, Strava, and auto-pause often split one long effort — a 5-hour run
with breaks — into several back-to-back exercise sessions. The New Workout
carousel offered each fragment as its own suggestion.

Add WorkoutMerger, which walks the detected sessions in start-time order and
folds any run of same-type sessions less than an hour apart into a single
DetectedWorkout: distance, calories, steps, elevation and duration are summed,
heart rate is duration-weighted, max heart rate is the max, and sessionCount
records how many were combined. Gaps are tracked per exercise type so a brief
cross-training block mid-run doesn't split the two run segments. The carousel
shows the combined count so the user sees it's one thing.

HealthConnectManager.readNewWorkouts now applies the merge before returning, so
the composer offers the whole effort as one post.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UHCr6tW2xAXPJUVZjZPvA
2026-07-27 04:46:23 +00:00
vitorpamplonaandgithub-actions[bot] 402122727e chore: sync Crowdin translations and seed translator npub placeholders 2026-07-27 04:43:01 +00:00
Vitor PamplonaandGitHub 5d7b629104 Merge pull request #3738 from vitorpamplona/claude/quartz-test-warnings-6wptk1
test(quartz): clean up compiler warnings across the test suites
2026-07-27 00:40:13 -04:00
Claude 03eeb02378 test(quartz): clean up compiler warnings across the test suites
Resolve all `:quartz:compileTestKotlin*` warnings without changing any
test's behavior:

- Drop redundant bare `Secp256k1Instance` "force crypto lib load"
  statements (and their now-unused imports). JVM object initialization is
  thread-safe and lazy, and every one of these tests exercises crypto, so
  the lib loads on first use regardless — the eager reference was a no-op
  the K2 compiler flags as an unused expression.
- PairingEventTest: use `assertIs` instead of `assertTrue(x is T)` + cast.
- MergeQueryCorrectnessTest: drop `!!` that smart-cast already made moot.
- PodcastCommentScopeTest / MintExceptionTest: widen the declared type so
  the `is` checks are genuine runtime checks rather than always-true.
- FetchAllIdleTimeoutTest / NostrConnectSignerServiceTest: opt in to
  `ExperimentalCoroutinesApi` at the class level.
- GiantReqStreamTest: name the `WebSocketListener` overrides' parameters
  to match the supertype.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyXakbYay4zNJ4cwoF4j7N
2026-07-27 04:10:53 +00:00
Vitor PamplonaandClaude Opus 5 8dcc78056d refactor(buzz): move the Backlog and Workflow boards into the channel overflow
The two new boards were added as top-bar icons, which put a Buzz channel back to
four (Canvas, Backlog, Workflow runs, ⋮) and truncated the title row the bar is
there to show — "nosfabrica.commun…" instead of the full relay. That is the
crowding #3729 had just removed; this branch predates it and the merge stacked
them.

Canvas keeps the only icon: it is the channel's shared document, i.e. content.
Backlog and Workflow runs are two more views of the channel, reached
occasionally, so they join Threads and Share in the overflow — and pick up the
`!isDm` gate the icons never had.

Also gives the job board a string resource; the branch's i18n pass left
"Backlog" hardcoded in JobBoardScreen and in the icon's contentDescription.

Adds cli/tests/buzz/agent-exec.sh, which covers what the two loop harnesses
stub out. job-loop.sh proves the scheduler drives *an* --exec program; nothing
committed exercised the real one — the wrapper that turns a job into a PR. With
a stubbed `gh` and agent (no network, credentials, or Claude Code) it asserts
the happy path end to end (task on stdin → agent → commit → push the job branch
→ PR url on stdout) and, as importantly, the paths that must fail: an agent that
changed nothing becomes a job error, an empty task is rejected before the agent
runs, missing scheduler env is a hard error rather than a silent no-op, an agent
that committed for itself is not double-committed, and the default-branch guard
holds — asserting `main` on the remote is left untouched. 19/19.

Verified on emulator-5554: the bar is Canvas + ⋮ again with the full relay name
visible, the menu reads Threads / Backlog / Workflow runs / Share / Members /
Leave, and Backlog still opens from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:37:22 -04:00
Vitor Pamplona ba1baa359f Merge branch 'main' into test/buzz-agent-support 2026-07-26 23:02:48 -04:00
Vitor PamplonaandGitHub 7956648f92 Merge pull request #3735 from vitorpamplona/fix/community-tab-first-frame
fix(chats): render the community tab correct on its first frame
2026-07-26 22:48:56 -04:00
Vitor PamplonaandGitHub 9e0260df2b Merge pull request #3736 from vitorpamplona/claude/buzz-community-redesign-vrovav
Redesign Buzz channel rows to match Concord style with activity previews
2026-07-26 22:48:40 -04:00
Vitor PamplonaandGitHub 20f00228d7 Merge pull request #3737 from vitorpamplona/claude/concord-channels-banner-to0nmb
Remove community banner from Concord home screen
2026-07-26 22:48:03 -04:00
Vitor PamplonaandClaude Opus 5 654c33fee8 fix(chats): render the community tab correct on its first frame
Leaving the Buzz community tab and coming back rebuilt the screen: Direct
Messages sat empty for about a second, and the channel list settled into a
different order than it had a moment earlier — every visit, and a different
order each time.

Three causes, all fixed here.

**The tab was destroyed, not left.** navBottomBar popped siblings without
`saveState`, so the entry — and its ViewModelStore — was thrown away on every
tab switch. Returning built new BuzzRelayImportViewModel / BuzzDmListViewModel
instances, whose bind() self-guard could not help because the guard lives on an
object that no longer existed. Adding saveState/restoreState keeps each tab's
state; other tabs get their scroll position back as a side effect.

**The DM inbox waited on the network to show what it already had.** bind()
called refresh() → discoverMemberChannels(), an 8s-timeout relay round-trip,
before anything could render — even though rebuildRows() reads nothing but
LocalCache and an in-memory map, and the always-on BuzzDmDiscovery has already
recorded those channel ids process-wide in BuzzDmChannels. Seed memberChannels
from that registry and project the rows before any network work; refresh still
runs behind it and corrects anything stale.

**The channel order was arrival order.** buzzGroupIds is "membership ids as the
ViewModel emitted them, then directory ids", and the only sort was
`sortedByDescending { it.id in starred }` — a stable sort over a boolean, which
preserves whatever landed first. Order by (starred, name) so the first frame is
the final order; a channel whose 39000 hasn't arrived sorts by id until its name
lands.

Verified on emulator-5554, capturing ~0.45s after the tab switch: channels in
alphabetical order and both DMs present, with no reshuffle in later frames.
Previously the same capture showed an empty Direct Messages section and a list
that reordered within the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:45:16 -04:00
Claude fc758e889b fix: remove community banner from Concord channels list
Drop the CORD-02 §6 banner hero that showed when a community was expanded
to OPEN in the Concord home screen, so opening a community and viewing its
channels no longer displays the banner. Removes the now-unused CommunityBanner
composable and its ContentScale/height imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6rz1csKDADKc5nr7R4a1b
2026-07-27 02:42:32 +00:00
Vitor PamplonaandGitHub bce35074fc Merge pull request #3734 from vitorpamplona/claude/cashu-token-claim-error-6s6dym
Support P2PK witness signing for pasted Cashu tokens
2026-07-26 22:28:23 -04:00
Vitor PamplonaandGitHub da41ba0b79 Merge pull request #3732 from vitorpamplona/fix/buzz-forum-post-only-in-forum-channels
fix(buzz): only offer "new thread" where a forum post belongs
2026-07-26 22:09:19 -04:00
Claude 3c440a6d27 fix(cashu): case-insensitive P2PK lock match + all-or-nothing redeem
Two bugs found auditing the P2PK redeem path:

- Hex case: a lock's `data` pubkey is sender-formatted and NUT-11 doesn't
  mandate a case, but our key index is keyed by lowercase x-only (Hex.encode
  is lowercase). An uppercase/mixed-case lock we actually hold the key for was
  falsely rejected as unredeemable. Normalize the lock to lowercase before the
  lookup, and compare identity-key locks case-insensitively.
- Multi-mint partial redeem: callers redeem one mint-group at a time, each
  swapping + publishing. An unsignable P2PK lock in a later group threw only
  after earlier groups were already spent + published, leaving a half-redeemed
  state the user was told had failed. Add `firstUnsignableP2pkLock` /
  `requireP2pkRedeemable` and pre-flight every group before redeeming any,
  mirroring the existing unknown-mint pre-check (wallet ViewModel + amy CLI).

Also document that P2PK.signWitness's `["P2PK"` prefix guard is load-bearing
for safety (prevents cross-protocol signature reuse when signing with the
identity key), not just for parsing. Adds tests for case-insensitive matching
and the pre-flight helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
2026-07-27 01:26:36 +00:00
Claude ee5efba88d perf(cashu): avoid needless wallet-key decrypt and secret parse on redeem
Follow-up to the P2PK redeem support:

- Gate the redeem signing-key gathering behind an actual P2PK lock. The
  wallet P2PK key is decrypted from kind:17375 via the signer — a network
  round-trip on a NIP-46 bunker (and a possible approval prompt). The common
  case (a plain, unlocked token) needs none of it, so only fetch keys when
  `anyP2pkLocked()` is true. Applies to both the wallet ViewModel and the amy
  CLI token-redeem path.
- Fast-reject in `P2PK.parseSecret`: NUT-10 well-known secrets are JSON
  arrays, so bail before the throwing JSON parse when the string isn't one.
  Redeem parses every proof's secret once, so this drops a thrown+caught
  exception per plain proof (also benefits the nutzap redeem path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
2026-07-27 01:21:29 +00:00
Vitor PamplonaandClaude Opus 5 3acec7ee87 fix(buzz): don't offer a canvas on a DM unless one already exists
The Canvas icon showed on every Buzz channel including DMs, and its edit FAB
offered to create one there. Buzz itself never does: its canvas entry needs
`hasCanvas || canEditNarrative`, and `canEditNarrative` is
`canManageChannel && selfMember !== null && channelType !== "dm"` — so a DM can
only ever *display* a canvas that already exists, never write one
(ChannelManagementSheet.tsx). We were advertising "start a shared document" in a
two-person conversation, and a canvas created there would have been ours alone
to see.

Mirror both halves: a DM gets the icon only when a canvas is already in cache,
and the canvas screen drops its edit FAB for DMs so what remains is read-only.
Non-DM Buzz channels are unchanged — icon always, editing always.

The has-a-canvas check reads the same BuzzWorkspaceStates registry the canvas
screen renders from and recomposes on `canvasUpdates`, so the icon appears when
the document lands rather than on the next visit.

Verified on emulator-5554: the Buzz DM with Vitor (no canvas) shows only the
overflow, where it used to show Canvas + overflow; `general` on the same relay
still shows Canvas + overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:20:05 -04:00
Claude 4d8cda2c57 i18n(buzz): extract the new agent-flow screens' strings to strings.xml
The workflow run board, the attestation screen, and the persona editor
hardcoded English. Move their user-facing copy to values/strings.xml under the
existing buzz_ naming convention and read it via stringRes, so the surface is
translatable through Crowdin like the rest of the app.

- Snackbar/section/def-editor strings are resolved in composable scope into
  vals (the coroutine lambdas and LazyListScope.section can't call stringRes).
- pillContent returns string res ids resolved by StatePill; a runHeadline()
  composable centralizes the task/workflow-id/placeholder line.

Not extracted (documented): near-unreachable non-composable validation strings
in buildAttestation/parseHeldAttestation, the WorkflowDefOption id fallback, and
the kind/model/provider/runtime option *values* (data, not prose).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-27 00:35:45 +00:00
Claude 012e326791 refine(buzz): agent-screen UX + workflow-board render polish
- Attestation time conditions echo the entered epoch as a readable UTC time
  in supporting text (mirrors the kind field) — no more eyeballing unix seconds.
- Agent-key picker shows an invalid-paste error inline on its own field
  (isError + supportingText) instead of far down the form.
- WorkingLine pulse animates via graphicsLayer (draw phase) instead of
  Modifier.alpha read in composition, so active cards redraw rather than
  recompose each frame.
- Hoist the shipped-result URL Regex to a process-level val.
- LazyColumn items carry contentType so sub-compositions are reused across scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-27 00:08:23 +00:00
Claude 412dc53e38 fix(buzz): make the workflow + job boards actually show runs (aggregate from the live subscription)
The boards derived their state from LocalCache.filter(kinds = 46xxx / 43xxx),
but LocalCache.filter only matches notes whose kind.isRegular() (< 10_000).
Every run/lifecycle/job kind is >= 43001, so the filter returned nothing and the
boards never displayed a single run/job against live data (verified with a probe:
a consumed 46020 matched 0, a 30620 def matched 1). Definitions (30620,
addressable) were the only thing that showed.

Aggregate straight off subscribeAsFlow, which accumulates the channel's stored +
live events (deduped by id) and re-emits the list — the data the aggregators
need. This also removes the per-batch whole-cache rescans.

- WorkflowRunBoardViewModel: base #h subscription + a nested by-author decisions
  subscription (rebuilt only when the approver set changes via distinctUntilChanged)
  so grant/deny now arrive live for every observer, not just once at open. Drop
  46004/46011/46012 from the fetch set — the aggregator can't correlate them.
- JobBoardViewModel: aggregate jobs + kind-7 upvotes from the one #h subscription.
- Real success/failure feedback: trigger/approve/deny/defineWorkflow return a
  result; snackbar only on confirmed publish; the sheet stays open on a failed
  trigger; the definition editor shows an error + a Publishing… state instead of
  hanging open and inviting duplicate 30620s.
- Gate write actions on isWriteable(): a read-only login no longer sees a false
  "Approved" success, and the New-run FAB is hidden.
- WorkflowRunAggregator.fold: parse each event's JSON content once.
- Empty-state hint in the New-run sheet when no definitions exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-27 00:03:33 +00:00
Vitor PamplonaandClaude Opus 5 7d4e4f1cc9 fix(buzz): only offer "new thread" where a forum post belongs
The Threads compose FAB keyed on relay dialect, not channel type: on any Buzz
relay it published a kind-45001 forum post into whatever channel you were in.
Buzz only ever creates those in a `t=forum` channel — its client mounts the
forum view for `channelType === "forum"` alone — so a 45001 in a `t=stream`
chat channel is a post that no Buzz user can see. The relay accepts it
(nothing there gates 45001 by channel type), which is exactly why the client
has to.

Gate the FAB on the channel's declared type when the host speaks Buzz, and
leave every other relay alone: there the same button writes a NIP-7D kind-11,
which is valid in any group. A Buzz channel whose kind-39000 hasn't landed yet
reads as null and fails closed — a FAB appearing a moment later beats
publishing into the wrong channel.

Reading is untouched. An existing thread still renders wherever it came from;
this only removes the offer to create one where it would be invisible.

The empty state said "No threads yet. Start one with the + button." while the
FAB was hidden, which was already wrong for non-members and is now wrong for
chat channels too. Added a read-only variant.

The gate is a named function rather than an inline condition so the allow-path
has a test: no relay reachable in a manual pass exposes a `t=forum` channel, and
a gate that only ever denies is indistinguishable from deleting the feature.
Denials are covered on-device — a Buzz stream channel loses the FAB, a
NIP-29 group (basspistol.org) keeps it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:48:41 -04:00
Claude 63ff055b53 feat(cashu): claim P2PK-locked tokens and clear errors when we can't
Pasting a P2PK-locked cashu token (NUT-11) into the wallet sent the proofs
to /v1/swap with no witness, so the mint rejected them with an opaque
`witness is missing for p2pk signature` 400. Only the NIP-61 nutzap path
signed witnesses; the generic redeem path had no P2PK support at all.

- quartz: add `signP2pkWitnesses` (pure, resolver-driven) + the
  `P2PKUnredeemableException` it throws when a locked proof's key is
  unknown, and a `CashuMintOperations.redeemToken` that signs then swaps.
- commons: `CashuWalletOps.redeemToken` now takes the wallet P2PK key and
  (local-signer-only) identity key, indexes them by x-only pubkey, and
  routes through the P2PK-aware path. Add `describeRedeemError`, which tells
  a user whose token is locked to their own identity key (e.g. Bey Wallet's
  P2PK send) — but who is on a bunker/external signer that can't sign a raw
  witness — to import their nsec elsewhere to claim it.
- amethyst: `CashuWalletState.redeemSigningKeys()` surfaces both keys
  (identity key only for a local NostrSignerInternal); the wallet ViewModel
  wires them in and reports via `describeRedeemError`.
- cli: `amy cashu receive token` passes the same keys and reports a distinct
  `p2pk_locked` error code.

Adds P2PKRedeemTest covering pass-through, x-only + compressed locks,
verifiable witnesses, and the unredeemable case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
2026-07-26 23:39:49 +00:00
Vitor PamplonaandGitHub ccc6b804ae Merge pull request #3731 from vitorpamplona/docs/buzz-readme-kind-conflicts
docs(quartz): correct the Buzz kind-conflict table and document how to read Buzz's source
2026-07-26 19:25:20 -04:00
Vitor PamplonaandClaude Opus 5 6a5d55aa9b docs(quartz): correct the Buzz kind-conflict table and document how to read Buzz's source
Two rows of "Kind conflicts — implemented but NOT registered in EventFactory" were
stale: 20001 and 39005 are both dispatched now, disambiguated by tag shape inside
the kind's block (`g` for BitChat presence, `h` for a Buzz thread summary). Split
the table into the disambiguated pair and the three where the incumbent really does
keep the slot, and record why the 39005 signal is safe — the NIP-29 relay-generated
39xxx family is `d`-addressed and never emits `h` — plus the fact that nothing
throws when it is wrong, so the failure is silent.

Also document reading Buzz's Rust without a checkout, which currently costs everyone
the same detour: KDoc across this package cites crate-relative paths
(`buzz-relay/src/handlers/...`) while the repo puts everything under `crates/`, and
`raw.githubusercontent.com` 404s on those paths with plain curl usually sandboxed —
`gh api ... contents/... | base64 -d` is the way in. Notes where the answers live
(handlers for what the relay emits, buzz-db/channel.rs for channel_type and the two
visibility values, desktop/src/features for what Buzz's own client renders — which
decides whether an event we publish is visible to anyone), and that the relay's tests
are the best spec: `channel_scoped_content_kinds_require_h_tags` is what establishes
that canvas and the forum kinds are per-channel, not per-workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:18:53 -04:00
Vitor PamplonaandGitHub 552479a637 Merge pull request #3728 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-26 19:14:42 -04:00
Vitor PamplonaandGitHub 81f0c0dd1b Merge pull request #3729 from vitorpamplona/refactor/relay-group-topbar-overflow
refactor(chats): demote Threads and Share to the channel overflow menu
2026-07-26 19:14:35 -04:00
Vitor PamplonaandClaude Opus 5 59646917e9 refactor(chats): demote Threads and Share to the channel overflow menu
The relay-group top bar carried four affordances — Threads, Canvas, Share and
the overflow — which crowded the title to the point that a channel called
personalized-knowledge-graphs rendered as "personalized-..." with its relay
truncated too.

Threads was the worst offender because it advertises a feature that is usually
not there. On a Buzz `t=stream` channel the threads list is forum posts (kind
45001), and Buzz only ever creates those in `t=forum` channels — which the
relay's channel list already surfaces in their own Forums section, opening
straight into this same view. So on every chat channel the button led to "No
threads yet", and its + would have published a 45001 into a stream channel,
where Buzz's own client never renders it (it mounts ForumChannelContent only
for channelType === "forum"). It stays in the menu rather than being gated off,
because on vanilla NIP-29 relays the same screen is the legitimate NIP-7D
kind-11 thread view.

Canvas keeps its icon: it is this channel's shared document — content — while
Threads and Share are navigation you reach for occasionally.

Also fixes the overflow only existing in the member branch: a pending join or a
gated group you don't belong to replaced the whole menu with the Join button, so
Members was unreachable while browsing. The menu is now always present, with the
membership actions (Members/Edit/Invite/Leave) gated as before and Threads/Share
always available — you can want to hand out a group you are still only browsing.

Canvas is per-channel, not per-workspace: the relay requires an `h` channel tag
on kind-40100 (its own channel_scoped_content_kinds_require_h_tags invariant),
so correct the empty state from "shared in this workspace" to "in this channel",
say so in the KDoc, and name the channel under the title the way Threads does.

Verified on emulator-5554: Buzz channel shows Canvas + overflow (Threads, Share,
Members, Leave) and the full title now fits; a non-Buzz group (basspistol.org)
shows only the overflow with the same four items; the canvas screen names its
channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:59:04 -04:00
Claude eff0a30a39 perf: skip chat warmup + activity preview for Buzz forum rows
Forum channels store their posts as threads (a separate store), not in the
chat notes the activity preview reads — so a forum row was opening a kind-9
chat warmup subscription that returns nothing and could never render a
last-message preview, facepile, or unread badge.

Add a `showActivityPreview` flag (default true) to BuzzImportRow that gates
the warmup, the notes-flow collection, and the preview/facepile/unread reads.
The forum section passes false, so a forum row skips the useless subscription
and shows a member-count summary instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
2026-07-26 22:57:49 +00:00
Claude 471a085b14 Merge remote-tracking branch 'origin/main' into claude/buzz-community-redesign-vrovav
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt
2026-07-26 22:50:56 +00:00
Claude aaf7affedc Merge remote-tracking branch 'origin/main' into claude/buzz-amethyst-support-nfxog7
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
2026-07-26 22:41:48 +00:00
vitorpamplonaandgithub-actions[bot] 8a284278fa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-26 22:29:10 +00:00
Vitor PamplonaandGitHub bc1cce6065 Merge pull request #3727 from vitorpamplona/feat/buzz-system-message-sentences
feat(buzz): say who joined and what changed in the kind-40099 system lines
2026-07-26 18:26:03 -04:00
Vitor PamplonaandClaude Opus 5 8df98c34df feat(buzz): say who joined and what changed in the kind-40099 system lines
The in-chat markers read "member joined" / "visibility changed" — the relay's
raw payload type with the underscores swapped for spaces. Everything that makes
the line useful was parsed and then dropped: which member, who added them, and
what the setting changed TO.

Render the full sentence from the signed payload instead, with the pubkeys
resolved to the names the viewer knows them by and the avatar of whoever the
line is about:

  member joined      -> "Shawn was added by straycat"  (actor != target)
                     -> "Vitor joined"                 (self-join, actor == target)
  member removed     -> "Bob was removed by Alice"
  visibility changed -> "straycat made this channel open — anyone can find and join it"
                     -> "... made this channel private — invite only"
  ttl changed        -> "Alice set messages to disappear after 7 days" / turned off
  topic/purpose      -> the new value, or "cleared the topic" when blank
  channel created/deleted/archived/restored, message deleted (+ public reason),
  dm created         -> named after their actor

The event is signed by the RELAY keypair, so the people come from the payload's
actor/target, never from note.author. Membership lines are about the member, so
that is whose avatar shows and whose profile the pill opens; everything else is
about the actor.

Also covers the neighbouring timeline narration, which had the same problem:
huddle joins/leaves now name the participant from the `p` tag instead of
"someone joined the huddle", job lines name the signer, and forum votes name
the voter. All of these move from hardcoded English into string resources.

Quartz's SystemMessagePayload was missing target_event_id, action_id,
reason_code, public_reason and participants — every field of the moderation
tombstone and the DM-created payload. The KDoc now carries the complete
vocabulary read off the relay's emit_system_message callers, and the type
strings are constants so the UI cannot typo a branch into dead code.

An unknown type still renders as "alice: some_new_type" rather than vanishing,
so a relay that grows new vocabulary stays legible.

Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: the four
"was added by" lines, "straycat created this channel", "Matthias Debernardini
joined" and "straycat made this channel open" all render with the right subject
avatar, in the chat and in the Messages-list preview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:19:01 -04:00
Vitor PamplonaandGitHub 2a25dfee01 Merge pull request #3724 from dergigi/fix/decode-numeric-html-entities
fix: decode numeric HTML entities in link preview meta tags
2026-07-26 18:11:33 -04:00
Claude e971a8ccd0 feat(app): pickers for agent key, kind, and persona model/provider/runtime
Replace raw-code text fields in the Buzz agent flows with pickers, matching
the workflow-definition picker and the app's existing people-typeahead.

- Agent Attestation "Agent public key": a single-agent people picker
  (name typeahead over the local user cache + removable chip), with
  npub/hex paste kept as the Enter escape hatch for keys that aren't
  contacts yet.
- Agent Attestation "Restrict to kind": an editable dropdown of common
  named kinds (1 Text note, 7 Reaction, …), free numeric entry preserved,
  with the resolved name shown as supporting text.
- Agent Persona edit "Model / Provider / Runtime": editable dropdowns of
  well-known values (claude-*, gpt-*, anthropic/openai/…, goose/…), free
  entry preserved since these stay free-form on the wire.
- New reusable EditableSuggestDropdown (BuzzOptionDropdown.kt) backing the
  open-ended pickers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 22:06:57 +00:00
Vitor PamplonaandGitHub a0cf0ec332 Merge pull request #3726 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-26 18:04:20 -04:00
vitorpamplonaandgithub-actions[bot] f17205b015 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-26 21:57:06 +00:00
Vitor PamplonaandGitHub 14381984a7 Merge pull request #3725 from vitorpamplona/fix/event-factory-39005-collision
fix(quartz): route kind-39005 by tag shape so Buzz thread summaries stop parsing as NIP-29 pin lists
2026-07-26 17:54:25 -04:00
Gigi 4cb0fb64af fix: decode numeric HTML entities in meta tag content
Link previews left &#34; / &#x22; literal because replaceCharRefs only
whitelisted named entities. Parse terminated numeric refs as code points.

Fixes #3723
2026-07-26 23:52:33 +02:00
Vitor PamplonaandClaude Opus 5 75a5e91681 fix(quartz): route kind-39005 by tag shape so Buzz thread summaries stop parsing as NIP-29 pin lists
kind:39005 is claimed by two unrelated relay-signed addressable events, and
EventFactory mapped it unconditionally to NIP-29's GroupPinnedEvent, leaving
Buzz's ThreadSummaryEvent unreachable:

  GroupPinnedEvent    d = group id, e = pinned message ids, no h, empty content
  ThreadSummaryEvent  d = thread root id, e = that root, h = channel, JSON content

Nothing throws on the mismatch, so a summary would have parsed as a pin list and
pinnedEventIds() would have reported the thread root as a pinned message.

Discriminate inside the kind's block on the `h` tag, following the kind-20001
BitChat/Buzz presence precedent already in this file. `h` is a safe signal in
both directions: the whole NIP-29 relay-generated 39xxx family (metadata,
admins, members, participants, supported-roles, pinned) is addressed by `d`
alone and never emits `h`, and neither builder does either, so both classes
round-trip through the factory on outbound signing as well.

Buzz publishes summaries live to channel subscribers (not only over its HTTP
bridge), so this is what makes it safe for a channel-scoped filter to ask for
39005 at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:52:12 -04:00
Claude 123a9545fb feat(app): full workflow-definition picker on the run board
Replace the free-text "Workflow id" field in the New Run sheet with a
dropdown of the channel's published workflow definitions (kind-30620),
shown by name, plus an inline editor to define a new one.

- Account.publishBuzzWorkflowDef: sign + publish a 30620 with a minted
  workflow UUID, name, and YAML recipe; returns the new id.
- WorkflowRunBoardViewModel: fetch + watch the channel's 30620 defs
  (#h-scoped) alongside the runs, expose them name-sorted as
  WorkflowDefOption, and drive defineWorkflow(name, yaml).
- NewRunSheet: WorkflowPicker dropdown + DefinitionEditor; a just-created
  definition auto-selects once it lands in the channel list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 21:28:28 +00:00
Vitor PamplonaandGitHub 38b085812d Merge pull request #3721 from vitorpamplona/fix/tor-guard-sample-self-heal
fix(tor): self-heal a guard sample that is rotten at runtime, not just on disk
2026-07-26 17:20:43 -04:00
Vitor PamplonaandGitHub 871d56e1a4 Merge pull request #3717 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-26 17:20:15 -04:00
Vitor PamplonaandGitHub 221cb84408 Merge pull request #3720 from vitorpamplona/claude/bolt12-zaps-nip-naming-qzw8zi
Rename NIP-XX to NIP-B1 for BOLT12 Zaps specification
2026-07-26 17:20:04 -04:00
vitorpamplonaandgithub-actions[bot] 6bf3c11173 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-26 21:18:45 +00:00
Vitor PamplonaandClaude Opus 5 e934d41bb2 fix(tor): self-heal a guard sample that is rotten at runtime, not just on disk
Tor wedged across app restarts with ~87% of relay connections failing, and
neither existing recovery fired. Captured from the device:

  guards.json  default: 60 guards, 59 disabled, 1 unlisted -> 1 usable
  Arti log     AllGuardsDown { n_accepted: 0, n_rejected: 60 }  x21,490
  sockets      36,007 failures vs 5,276 successful opens

Everything above Tor degraded with it. Concord was the visible casualty: its
control-plane sync drained exactly 12 wraps on every launch and never folded, so
the Messages tab showed zero Concord rows for two restarts running.

Two recoveries exist and this state slipped between both:

- `ArtiGuardState.hasNoUsableGuards` required `usable == 0`. Its own KDoc claimed
  "a single usable guard is enough to recover" — the device disproved it. One
  survivor that is merely *unreachable* is useless for recovery but sufficient to
  veto it, so the wipe never ran.
- `TorManager`'s watchdog only arms while status sits at Connecting. Tor had
  bootstrapped: the SOCKS proxy was bound, `hasEverBootstrapped` was true, ~13% of
  connections still worked, so status reached Active and the watchdog never fired.
  It is built for "Tor never came up"; this is "Tor came up and its guards rotted".

Nothing observed the steady-state failure rate, so all three gates evaluated the
same way on every launch and `guards.json` carried the wedge forward forever.

1. Proportional disk rule. A sample of at least MIN_SAMPLE_TO_JUDGE_RATIO whose
   usable guards fall under 1/USABLE_RATIO_DIVISOR of the total is wedged. Below
   that size only the strict `usable == 0` rule applies, so a young sample Arti is
   still filling is never wiped out from under a legitimate first bootstrap.

2. Runtime detector. `TorService` counts Arti's own AllGuardsDown log lines
   (GUARDS_DOWN_THRESHOLD within GUARDS_DOWN_WINDOW_MS) and exposes
   `TorBackend.guardsDownSignal`; `TorManager` routes it through the same
   rate-limited self-heal to `resetWithCleanState()`. This is the general fix: it
   believes Arti when it says every guard was rejected, so it fires even while
   `guards.json` still looks healthy and status is Active — precisely the blind
   spot between the two older heuristics. The count lives in the log callback
   because that is the only place Arti surfaces it; the reset stays in TorManager,
   which owns the cadence.

Verified on the wedged device. Next launch, unprompted:

  W TorService: No usable Arti guards left on disk — wiping state to rebuild the guard sample

  guard sample   60 total / 1 usable   ->  20 total / 20 usable / 0 disabled
  AllGuardsDown  21,569                ->  0
  sockets        36,007 fail / 5,276   ->  444 fail / 232 open
  Concord wraps  12, 12, 12 (pinned)   ->  12 -> 37 -> 258
  Concord rows   0                     ->  4 and climbing

Tests cover the 59-of-60 field case, a small-sample false-positive guard, and a
healthy-majority sample. The pre-existing 22-guard "poisoned but not wedged"
fixture still passes, so the ratio does not regress the earlier variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:18:26 -04:00
Vitor PamplonaandGitHub dfbe2b9e61 Merge pull request #3718 from vitorpamplona/fix/buzz-minichat-kind9-replies
fix(buzz): thread kind-9 replies into the minichat instead of the channel
2026-07-26 17:16:04 -04:00
Claude 8854dfc156 feat(app): confirm step + honest feedback on the workflow approval gate
Approving publishes a signed grant that makes the runner push code and open
a PR — too consequential for a single stray tap. Tapping Approve (or Deny)
now raises a confirm dialog that states the boundary plainly ("Approving
never merges or deploys — that still happens on GitHub"); denying warns the
work is discarded. On confirm, a snackbar gives immediate, truthful feedback
("Approved — the runner is opening a pull request"), and the card moves to
Working/Approved, then genuinely lands in Shipped with the PR link when the
runner's 46005 arrives — rather than faking "PR opened" at tap time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 20:45:32 +00:00
Vitor PamplonaandGitHub eedc8c7a08 Merge pull request #3719 from davotoula/fix/audio-player-overflows-note-layout
Stop the inline audio player painting over the note
2026-07-26 16:43:33 -04:00
Claude 67223d612f refactor: rename BOLT12 zaps placeholder NIP to the assigned NIP-B1
The BOLT12 zaps feature was built against a placeholder NIP identifier
("nipXX" / "NIP-XX", and "NIP-2421" in one plan). The number NIP-B1 has
now been assigned, so update the naming across every module:

- rename the Quartz package `nipXXBolt12Zaps` -> `nipB1Bolt12Zaps`
  (commonMain + commonTest) and every import referencing it.
- KDocs/comments: `NIP-XX` -> `NIP-B1` in quartz, commons, amethyst, cli.
- wire binding prefix: `nostr:nipXX:` -> `nostr:nipB1:`
  (Bolt12ZapValidator.NIP_URI_PREFIX, NIP-47 pay `payer_note`, tests).
- KindNames: Bolt12 Zap / Bolt12 Offers NIP number "XX" -> "B1".
- plan doc references `NIP-2421` -> `NIP-B1`.

Leaves the unrelated `nipXXPodcasting20` package and the audio-rooms
draft (also placeholder "NIP-XX") untouched. quartz main + test compile
and spotless is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012P3krSe92wicpswBr2CP9s
2026-07-26 20:32:16 +00:00
Claude 10abcbe82a refine(app): label the gate action "Approve & open PR"
"Approve & ship" over-promised — approval pushes the branch and opens a PR;
it never merges or deploys (merge stays a human action on GitHub). The
clearer label states the actual boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 20:26:09 +00:00
Vitor PamplonaandClaude Opus 5 07e3f21509 fix(chats): light the Messages badge for every row type, notify Buzz thread replies
Two halves of the same gap: a row could show its blue dot while nothing above it
agreed.

1. Bottom-bar envelope counted only DMs
------------------------------------------------------------------------------
`messagesHasNewItems` mapped each Messages row through `unreadPrivateChatRoute`,
which opens with `if (newestMessage !is ChatroomKeyable) return null`. Only
NIP-17/NIP-04 DM events implement that interface, so eight of the nine row types
were silently skipped — public chats, ephemeral rooms, geohash cells, Marmot
groups, NIP-29/Buzz channels, Concord channels, and both collapsed "grouped"
rows. Each of those rows already computed its own dot from its own last-read
route; the badge just never asked.

`rowHasUnreadFlow` now answers, per row, the same question the row composable
answers for itself, keyed off the note's gatherer (a Buzz channel and a Concord
channel can both carry a kind-9, so event kind alone can't tell them apart). It
returns a Flow rather than a (route, createdAt) pair because the two collapsed
rows fan in over every child channel — approximating those by their newest child
would miss an older channel that is still unread.

2. Buzz thread replies notified nothing
------------------------------------------------------------------------------
Buzz's clients thread with `["e", <id>, "", "reply"]` and only ever `p`-tag
@mentions, so a reply to my message names me nowhere. `isNotifiablePublicChatRep
ly` — the rule that lets a reply notify without a `p` tag — bails unless the
event is a ChannelMessageEvent (kind 42), so a kind-9 thread reply qualified
under nothing. Combined with thread replies now being kept out of the channel
timeline and its unread dot, a reply to my message in a Buzz channel had become
invisible on every surface.

`isBuzzThreadReplyToMyEvent` mirrors the fix already used for Buzz reactions
(`isReactionToMyEvent`): when a chat event carries no `p` tag, resolve the author
of its `root`/`reply` marked `e` targets instead of trusting a tag. Deliberately
only the MARKED targets — a bare `e` is WhiteNoise/Marmot's in-chat reply, not a
thread. It is OR'd into the same three gates the reaction case uses, so a reply
from a channel member I don't follow still notifies.

Also admits kind-40002 into NOTIFICATION_KINDS: nothing writes it any more, but
legacy Buzz thread replies exist and were being dropped at the kind gate before
any relevance check ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:22:22 -04:00
davotoulaandClaude Opus 5 3813e248c2 i18n: translate Buzz, Blossom import and BOLT12 strings into cs, de, pt-BR, sv
Fills the on-disk translation gap for the recently merged Buzz workspaces,
Blossom file import and BOLT12 offers features, plus the channel-invite and
payment-notification strings.

Inserted per-locale (128 cs, 124 de-rDE, 126 sv-rSE, 128 pt-rBR) driven off
each file's own diff rather than a shared union block, since Crowdin strips
source-identical keys asymmetrically across locales.

All 7 new <plurals> are covered in every locale; Czech gets the full CLDR
one/few/many/other set. Source-identical entries (Canvas, Workspace, OK,
LIVE, Media, Social, Reposts, and the bare %1$d+ count formats) are
deliberately left out — Crowdin strips those on export and Android falls
back to values/strings.xml at runtime.

Verified: no duplicate keys, well-formed XML, and format-placeholder parity
with the English source across all four files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45dBdJ8GRBy8zw9yDQRPW
2026-07-26 22:21:24 +02:00
Vitor PamplonaandClaude Opus 5 4f6e16a74e fix(nip29): line up preview, timeline and unread on one predicate
A minichat thread reply was showing as a group's "last message" on Messages, and
lighting its unread dot, even though the channel timeline correctly hides it.
Three surfaces disagreed about what counts as a message:

  channel timeline  ChannelFeedFilter   !isMinichatReply(..) && isAcceptable
  Messages preview  newestChatNote      isGroupChatContent()  && isAcceptable
  unread dot        hasChatNewerThan    isGroupChatContent()  && isAcceptable

So a reply that the channel deliberately routes to its thread still became the
row summary, and still lit the dot — you would open the group, see nothing new,
and watch the dot clear. The Concord side already solved exactly this by sharing
one predicate (`isConcordTimelineMessage`) across feed, preview and badge; this
gives NIP-29 the same treatment:

    isRelayGroupTimelineMessage = isGroupChatContent && !isMinichatReply && isAcceptable

Now used by:
- `newestTimelineNote` (replaces the local `newestChatNote`) for the row preview,
  in both INLINE and GROUPED view modes
- both additive paths in ChatroomListKnownFeedFilter, so an arriving reply cannot
  bump a row either
- `hasChatNewerThan`, which backs the per-channel dot (`relayGroupChannelHasUnread
  Flow`, also used by the workspace channel list), the collapsed per-relay dot
  (`relayGroupServerHasUnreadFlow` composes it), and transitively the Messages row
  dot, which reads the createdAt of the note the preview picked

The bottom-bar Messages badge follows automatically: it derives from the feed rows
themselves. (It only counts private DMs today — `unreadPrivateChatRoute` returns
null for anything that isn't ChatroomKeyable — which is a separate, pre-existing
scope decision, untouched here.)

Verified on device by creating the case rather than waiting for it: posted a reply
into a thread so it became the newest event in the channel. The thread shows it
(chip 4 -> 5 replies), the channel timeline does not, and the Messages row still
previews the newest timeline event. Before this change that reply would have been
the row summary. The earlier screenshot only looked right because a system message
happened to be newer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:00:49 -04:00
Vitor PamplonaandClaude Opus 5 ec0c4a6c2a fix(buzz): thread kind-9 replies into the minichat instead of the channel
A reply written by any current Buzz client is a kind-9 carrying a NIP-10
`reply`-marked `e` tag. Amethyst rendered it as a flat row in the main channel
with the parent quoted above it, and it never appeared in the thread on its
parent — the exact inverse of where Buzz puts it. Observed on the wire:

  parent  kind 9  tags: [h, <channel>]
  reply   kind 9  tags: [h, <channel>], [e, <parent>, "", "reply"]

`isMinichatReply` is the single definition three consumers share — the channel
timeline filter drops these, the reply-count chip counts them, and the minichat
feed shows them — but it was type-gated to CommentEvent (1111) and
StreamMessageV2Event (40002), so a kind-9 ChatEvent fell through to `false`.
Every downstream behaviour followed from that one gap: the reply stayed in the
timeline, the parent showed no "N replies" chip, and the thread was empty.

Accepting a marked `e` on kind 9 is NIP-C7 compliant. C7 defines exactly one
reply mechanism for kind 9 — `["q", <id>, <relay>, <pubkey>]` — and never
mentions `e` at all, so a marked `e` carries no C7 meaning and is free to denote
a thread reply. Matching on the MARKER (never the bare tag) is what keeps
WhiteNoise/Marmot working: they thread kind-9 chat with a plain, unmarked `e`,
which is an in-chat reply and must keep rendering as a quote bubble. Tests pin
all four cases: marked direct, marked nested (root+reply), unmarked, and `q`.

Also stop writing kind-40002 for Buzz minichat replies. Nothing in Buzz writes
40002 any more: every send path in their mobile, desktop and CLI clients emits
kind 9, the ~50 remaining references are all reads (filter kind lists, feed
query sets, archive constants), and their NOSTR.md grades kind:9 as supported
against 40002's "Buzz-only — no standard NIP-29 client renders these". It is a
read-compat tail from the 10002 -> 40001 -> 40002 migration, and Amethyst was
the last active writer — so our replies threaded nowhere but our own client.
We now emit kind 9 with tags byte-identical to Buzz's `_buildReplyTags`
(direct -> one `reply` marker; nested -> `root` + `reply`), which is what
`buzzThread` already produced. Reading 40002 stays supported for events already
in the wild, including the ones we wrote.

Deliberately NOT changed: `computeReplyTo`'s ChatEvent branch still links every
`e` tag to the parent. That link is what populates `note.replies`, which the
thread and the chip read — discriminating there would unlink Buzz replies from
their parents. The marker distinction belongs in rendering, not in linkage.

Verified on device against a real thread: the reply now sits in the thread on
"howd you get bumble working?" with a "3 replies" chip on the parent, and is
gone from the channel timeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:14:15 -04:00
davotoula 482be60e4a Code review:
- fold audioExt into videoExt and share the classifier
2026-07-26 20:26:52 +02:00
davotoula b2b2adf076 fix(audio): stop the inline audio player painting over the note 2026-07-26 20:23:15 +02:00
Claude 2e68af8296 feat(app): elevate the workflow/job boards — dramatize the approval gate
Design polish pass on the Buzz boards, focused on the moment that matters:
a human authorizing an AI to ship code.

- The approval gate is now the board's centerpiece: an elevated, softly
  pulsing card (glow border + gradient) with a filled circular gavel badge,
  a headline-sized task, and a dominant "Approve & ship" action (Deny stays
  a quiet text exit). Non-approvers see a "waiting on <approver>" pill.
- Real depth: state-tuned shadow elevation on every card, soft-filled status
  pills, and section headers as an uppercase label + count chip.
- Motion that reads as "alive": an indeterminate progress bar on actively
  running work (both boards), alongside the existing working-line pulse.
- Matching pass on the job board so the two surfaces stay one visual system.

No behavior change; pure presentation. Amethyst compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 17:58:35 +00:00
Claude 994bd8ee15 docs(cli): record the landed Android workflow board in the plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 17:23:49 +00:00
Claude 0099d20597 feat(app): Buzz workflow run board + approval gates on Android
Phase 2 of the Buzz workflow support: a per-channel mobile surface for the
source-confirmed workflow primitive, mirroring the existing job board but
built around the human-approval gate.

- WorkflowRunBoardScreen + WorkflowRunBoardViewModel (per channel, entered
  from RelayGroupTopBar on Buzz relays via a new Route.BuzzWorkflowBoard).
  Folds the workflow kinds via the shared WorkflowRunAggregator, groups runs
  by state with "Needs your approval" pinned first, and lets the named
  approver grant/deny a paused run inline (46030/46031). A shipped run shows
  its PR; merge stays on GitHub.
- Account: triggerBuzzWorkflow / approveBuzzWorkflowRun / denyBuzzWorkflowRun
  (same sign → local-echo → publish-to-group-relay contract as the job
  helpers).
- RelayGroupFilterBuilders: subscribe the #h-scoped workflow kinds (46020,
  46001-46007, 46010-46012) on every group REQ. The client-signed
  grant/deny (46030/46031) carry no h tag, so the board fetches them by
  author — every 46010 gate names its approver in a p tag — matching the CLI.
- NotificationFeedFilter: a workflow approval gate (46010) addressed to me
  notifies and is push-eligible (added to NOTIFICATION_KINDS + an
  acceptableEvent early-return gating on approver()==me).

The quartz EventFactory registration and LocalCache ingest for these kinds
already existed, so no protocol/ingest changes were needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 17:22:14 +00:00
Vitor PamplonaandGitHub 537b75a451 Merge pull request #3716 from vitorpamplona/fix/nip29-buzz-channel-delivery
fix(nip29): deliver Buzz/NIP-29 channel updates live, and ask before showing channels you were added to
2026-07-26 12:59:31 -04:00
Claude 5d32cc8396 feat(cli): drive Buzz workflows from amy (trigger, run, approve/deny)
Switch the agent-support-channel prototype from the speculative agent-job
kinds (43001-43006, reserved with no upstream builder) to Buzz's real,
source-confirmed workflow primitive: 30620 def / 46020 trigger / 46001-46007
lifecycle / 46010 approval gate / 46030-46031 grant-deny (pinned against
buzz-relay's command_executor.rs). This bakes the human-approval gate into
the protocol — anyone in the channel can drive a run, but a human grants
before anything is pushed.

- commons WorkflowRunAggregator folds trigger + lifecycle + grant/deny into
  per-run state (TRIGGERED/RUNNING/AWAITING_APPROVAL/APPROVED/COMPLETED/
  FAILED/DENIED), correlating by run id (= trigger event id = approval token);
  8-case test.
- cli `amy buzz workflow` — trigger/list/show/approve/deny plus the `run`
  runner: per new trigger it does the agent work in a fresh worktree+branch,
  posts the 46010 gate, and on a later poll runs --on-approve (push + PR) and
  emits 46005 completed; a deny discards the worktree (run is DENIED).

On a real Buzz relay the relay executes the workflow YAML; self-hosted on
geode there is no engine, so amy is the runner and emits the lifecycle events
itself (documented divergence). Two store realities, both verified against
geode: decisions are fetched by author (quartz's store serves #d only for
addressable kinds, and 46030/46031 are regular), and the runner is
restart-safe (runs at the gate are rebuilt from the deterministic run id).

Also hardens the exec helper against a broken pipe when --exec doesn't read
stdin, and fixes worktree teardown to run git against the owning repo.

End-to-end headless harness (cli/tests/buzz/workflow-loop.sh) covers the
grant and deny paths through an embedded geode relay: 14/14 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 16:50:54 +00:00
Vitor PamplonaandClaude Opus 5 bb87d7755a feat(buzz): ask before showing channels somebody added you to
On a Buzz relay, channel membership is server-side: another member can add you,
the relay writes you into the kind-39002 roster, and you can read and post
immediately. The relay then addresses you a kind-44100 naming who did it.

Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which
silently subscribed you to that channel's messages, while the Messages list
(which reads the self-published kind-10009) showed no row for it. A channel could
therefore be joined, streaming, and invisible at the same time: the channel
screen offered no Join button and accepted posts, the RelayGroups screen listed
it from the relay's 39000 directory, messages arrived — and Messages had nothing.

Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id",
"actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`,
so the actor is the only thing separating "I joined this" from "somebody put me
here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/
workflow — read through a dedicated accessor because on buzz the type shares the
tag name with real hashtags): only `t = dm` belongs in the DM list, everything
else becomes a pending invite that subscribes to nothing.

The prompt appears on both surfaces, driven by one state holder so they cannot
disagree — Notifications, in the same header slot as the missing-inbox-relay
prompt, and Messages > New Requests, beside the pending DMs it is the exact
analogue of. Rendered as a list row rather than a modal: these arrive in bursts
when somebody sets up a workspace, and a blocking dialog on cold start would be
miserable. It is also the spam surface, so Ignore stays cheap.

Three actions, and Ignore is deliberately not Leave:

- Show    -> writes the group into kind-10009 (Account.follow), after which the
             ordinary joined-group path owns it and it syncs to other devices.
             No kind-9021: the relay already has you in the roster, so this
             records only your decision to surface it.
- Ignore  -> local, reversible display choice. You stay in the roster and can
             still open and post.
- Leave   -> kind-9022 LeaveRequestEvent, the one that actually removes you.

A kind-44101 removal now withdraws any pending prompt, so the relay taking the
membership away cannot leave a card offering an action that would fail.

The invites section is passed as the chatroom feed's header rather than stacked
beside it: the collapsing top bar draws over that area, so a header outside the
list renders underneath it. It shows in the empty state too, otherwise an account
with no pending DMs would have no way to reach the prompt.

Verified end to end on device: "straycat added you to personalized-knowledge-
graphs" rendered on both surfaces, and Show republished kind-10009 with the
channel appended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:44:36 -04:00
Vitor PamplonaandClaude Opus 5 f434e98b00 fix(nip29): scope group subscriptions per channel so Messages updates live
The Messages list showed a stale last message for every NIP-29 group while the
open chat screen stayed live. The joined-group tail batched every group on a
host relay into ONE filter carrying all their ids in `#h`. That is valid NIP-01
and relays answer it correctly for stored queries — which is what made it so
confusing: the boot backfill populated every group, so the list looked right
until it needed to change.

`block/buzz` indexes each live subscription under a single channel uuid resolved
from `#h`. When two or more distinct ids appear anywhere across a subscription's
filters, `extract_channel_id_from_filters` returns None and the subscription is
registered as *global* — and global subscriptions deliberately never receive
channel-scoped events, guarding against leaking private channel content to a
subscriber whose membership was not checked per channel. So the batched tail
backfilled at EOSE and then went permanently deaf.

Measured on device: same relay, same connection, same subscription id, only the
`#h` count changed — one value delivered live, two delivered nothing.

The resolver scans every filter of a subscription, so splitting into one filter
per group is not enough; each channel needs its own subscription. The EOSE
managers are therefore keyed on GroupId, and the preloads mount one subscription
per joined channel. Relays leave room for this: buzz allows max_subscriptions
1024 against max_filters 10, so this also sidesteps the filter cap for anyone in
more than ten groups.

Also folded into the same per-channel subscriptions:

- Group activity addressed to me (reactions/zaps/replies) moved off the
  account-wide notifications subscription. That one also carries inbox filters
  with no `#h` at all, and buzz forces a subscription global on the first
  channel-less filter, so no reshaping there could ever have worked.
- Reactions and deletions (kinds 5/7/9005) now ride the channel's own `#h`
  subscription, the shape Buzz's own client uses (`channelEventKinds`). Amethyst
  otherwise learned about them only through the shared `#e` EventFinder query,
  which carries no `#h` and is therefore global — so reaction chips appeared
  only on a re-query, never as they happened. Kept out of the timeline kind set
  so reactions cannot consume a history page's limit and walk the `until` cursor
  past undelivered messages.
- A `limit = 1` preview filter with no time floor. The tail floors at now-7d to
  keep recent chat warm; on its own that stranded any channel quiet for longer
  on a "No messages yet" placeholder sorted to the bottom by createdAt 0. Every
  other roster-driven protocol on that screen already bounds by count for this
  reason (NIP-28 `limit = 1`, Concord `limit = 10`) — their row set comes from a
  list event, unlike NIP-17/NIP-04 whose rooms are discovered from the messages
  and so need the backward pagers.

The Messages row now says "Loading" instead of "No messages yet" until the fleet
settles, so an in-flight channel is not mistaken for an empty one. That required
the shared WindowLoadTracker to describe the whole joined fleet rather than
whichever channel rebuilt last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:44:05 -04:00
Claude 2e92d787a8 feat(buzz): notify the requester when their agent job finishes or fails
Finished/failed jobs now land in the Notifications tab, addressed to the requester.

1. NotificationFeedFilter: an early-return branch accepts a JobResultEvent (43004) or
   JobErrorEvent (43006) when it p-tags me (the requester) and isn't my own event —
   mirroring the existing Buzz-DM branch, since I don't "follow" the workspace bot and
   the job kinds aren't in the generic relevance path. It maps to the generic NoteCard,
   so it renders the result (PR URL) / error text.

2. JobErrorEvent now carries the requester as a `p` tag (new requester() accessor + a
   `requester` param on build, mirroring JobResultEvent); the scheduler passes
   job.requester on both error paths. Previously a failed job wasn't addressed to anyone,
   so a failure could never notify. Tests updated.

Relay sourcing (verified): a job outcome reaches LocalCache via the always-on `#h`
joined-group chat tail on the workspace relay (RELAY_GROUP_ALL_TIMELINE_KINDS includes
43001-43006), so for a shared channel the team has joined, results are pulled continuously
and now notify. A channel you haven't joined (or a job event with no `#h`) would still need
a dedicated `#p`=me subscription (mirroring BuzzDmDiscovery) — not added, since the
support-channel model always has members joined.

App compiles (fdroidDebug); quartz + commons tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 15:05:31 +00:00
Claude 0b19a89b0c feat(buzz): geode BuzzMembershipPolicy — self-host the agent channel
A thin server-side policy so `amy serve --buzz` hosts a private, agent-authorized
Buzz workspace on one JVM process — no Block Rust relay + Postgres/Redis/MinIO.

- quartz: BuzzMembershipPolicy : FullAuthPolicy (buzz/relay/). Runs the NIP-42
  handshake, then layers Buzz's two authorization rules: (1) only members (the team)
  may read/write; (2) NIP-OA virtual membership — an un-enrolled agent key is granted
  membership for its connection when its AUTH event carries an owner-signed `auth` tag
  whose owner is a member and whose signature authorizes that agent. An unauthenticated
  read is told `auth-required` (not `restricted`) so the client runs NIP-42 and retries.
  Deliberately does NOT emit relay-signed 39000-39003 metadata or run workflows (a
  policy can't emit events; the job channel doesn't need them). 8 unit tests.
- cli: `amy serve --buzz [--members npubs]` composes the policy into geode's RelayEngine.
  Members = admins + --members.

Verified end-to-end against a live `amy serve --buzz`: a member writes (published:true),
an outsider is rejected (restricted: not a workspace member); plus the 8-case unit suite
(member/non-member/unauth/reads/allowed-kinds/NIP-OA-agent/non-member-owner/tampered).

Plan doc + cli README updated with the two relay options (amy serve --buzz vs the Rust
stack) and when you'd need each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-26 14:12:51 +00:00
Claude a7d5fbfa8a feat(buzz): reference --exec wrapper that turns jobs into PRs
tools/buzz-agent/agent-exec.sh — the last mile from the scheduler to a live channel.

Honors the `amy buzz agent serve --exec` contract: reads the task on stdin, runs a
coding agent (Claude Code by default, or any $AGENT_CMD) inside the job's git worktree,
verifies a diff exists, commits, pushes the `claude/job-*` feature branch, opens (or
reuses) a PR, and prints the PR URL as the job result (kind-43004); any failure exits
non-zero → job error (kind-43006). It never touches the default branch and never
force-pushes — merge stays a human action on GitHub.

README documents the load-bearing guardrails, since Buzz enforces none of them: a
PR-only fine-grained token (Contents + Pull requests write, nothing else), branch
protection on the default branch (require PR + review + CI, block force-push/deletion),
and scoped intake (--accept-from) + agent tool allowlist.

Verified end-to-end against a throwaway repo with a stubbed gh + agent (happy path
pushes the branch and prints the PR URL; no-change path errors cleanly). Plan doc
follow-up #3 marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 20:53:21 +00:00
Claude 62799ac08c fix(buzz): harden agent scheduler + instant board feedback (audit round 2)
Second batch of audit fixes (an independent review confirmed C1 and surfaced these).

Scheduler (BuzzAgentCommands):
- H1: the runForever poll ran directly in supervisorScope, so a transient relay
  error/drain-timeout thrown by selectPending killed the unattended daemon. Wrap each
  poll in try/catch (rethrow CancellationException) and keep looping.
- H2: worktrees + branches leaked on Ctrl-C/kill (coroutine finally doesn't run) and a
  leftover branch made a job permanently un-runnable on rerun. Add a JVM shutdown hook
  that force-removes still-active worktrees/branches, and use `worktree add -B` so the
  branch is reset-or-create (idempotent).
- M2: worktree lifecycle moved inside the try so the finally always cleans up, including
  the failed-setup early return.
- M3: runOnce isolated each job in a try/catch so one throw can't cancel the batch.

App:
- M1: fileBuzzJob/upvoteBuzzJob/cancelBuzzJob now cache.justConsumeMyOwnEvent(signed)
  before publish, so the board reflects the action immediately (publish only sends to
  relays; the event wasn't in LocalCache yet, making the optimistic reload a no-op).
- L2: RelayStatusBar derives this relay's booleans with derivedStateOf, so global relay
  churn no longer recomposes the whole bar.
- L4: the upvote reaction now carries NIP-25 `p` (job author) and `k` (kind) tags.
- L5: JobBoardScreen DisposableEffect keyed on (channelId, relayUrl).

Verified: cli/tests/buzz/job-loop.sh 11/11 green; app compiles (fdroidDebug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 20:39:43 +00:00
Claude 76524264a9 fix(buzz): drain agent-exec pipes concurrently; memoize board buckets
Audit fixes.

- BuzzAgentCommands.runExec: the responder read the child's stdout fully and THEN
  its stderr, which deadlocks a chatty child (a coding agent easily fills the ~64 KB
  stderr pipe while we block on stdout) and let the child hang past --exec-timeout
  (readBytes has no timeout). Now stdout/stderr are drained on their own coroutines
  and stdin is fed on another; the timeout is enforced by waitFor, and on expiry
  destroyForcibly closes the pipes so the readers finish. Same concurrent-drain fix
  applied to the git() helper. Verified: cli/tests/buzz/job-loop.sh 11/11 green.
- JobBoardScreen: bucket the backlog into a JobGroups holder under remember(jobs)
  so the four filter/sort passes run once per new backlog, not on every recomposition
  (e.g. each isLoading toggle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 20:30:35 +00:00
Claude 5e8f079045 feat(buzz): elevated Jobs board UI + observable relay status bar
Make the shared backlog modern, alive, and transparent.

- RelayStatusBar: a live, tappable header that makes ALL of the workspace relay's
  state observable in context — connection (client.connectedRelaysFlow), NIP-42 auth
  phase (authCoordinator.authStateFlow, with a pulsing dot + lock icon), Buzz-dialect
  marker, and an expandable NIP-11 panel (software/version, supported NIPs, limitations,
  description, latency). Every signal is a real StateFlow/produceState, so it tracks
  reality live. Reusable across agent surfaces.
- JobBoardScreen redesign: "Working now" is the visual hero (primaryContainer, a
  breathing pulse on the streaming progress line, elapsed time); queued cards carry a
  colored status rail and reorder by upvotes with animateItem; shipped cards are a
  success tone with a prominent "View PR" (extracts the PR URL from the result);
  closed cards mute. Real avatars + display names (UserPicture/UsernameDisplay/LoadUser)
  instead of hex. Upvote chip animates its count; the composer is a friendly
  ModalBottomSheet with example chips; a warmer empty state.

App compiles (fdroidDebug). No font regen (reused existing MaterialSymbols).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 20:18:37 +00:00
Claude ed574bf023 feat(buzz): shared per-channel Jobs board (P0) for the agent backlog
Evaluate placement, then add the first interactive agent surface in the app.

Placement: the existing agent screens (AgentConsole/Attestation/PersonaEdit) are
owner-global telemetry entered per-relay and buried behind a channel-list footer.
A Buzz job is h-scoped to a channel, so the backlog is per-channel — it belongs
where Canvas/Forum already live (RelayGroupTopBar, gated by BuzzRelayDialect.isBuzz),
not inside the owner Console. Keep the two surfaces separate.

- JobBoardScreen + JobBoardViewModel (ui/screen/loggedIn/buzz/): the shared backlog
  of one channel. Reads the job kinds (43001-43006) + their kind-7 upvotes scoped to
  the channel h, folds via the shared BuzzJobAggregator, groups by lifecycle state
  (In progress / Queued-by-upvotes / Done / Closed), live via subscribeAsFlow. Every
  member sees the same board.
- Three write actions via new Account helpers: fileBuzzJob (43001, FAB → New task
  dialog), upvoteBuzzJob (kind-7 "+" e-tagging the job, h-scoped so the scheduler +
  board count it), cancelBuzzJob (43005, own jobs only). Merge is deliberately NOT
  here — a done job's result is its PR; merge happens on GitHub.
- Route.BuzzJobBoard(channelId, relayUrl) + AppNavigation wiring + a Checklist entry
  in RelayGroupTopBar next to the Canvas action.

Reuses the AgentConsoleViewModel fetch/watch pattern and existing MaterialSymbols
(no font regen). App compiles (fdroidDebug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 18:57:21 +00:00
Claude e0d6febd5b feat(cli): parallel backlog scheduler for the shared Buzz agent channel
Evolve `amy buzz agent serve` from a sequential responder into a scheduler that
manages a shared feature-request backlog by itself — the model where a whole team
drives an AI in one channel, not a 1:1 chat.

- Parallel execution with isolation: `--parallel N` runs up to N jobs at once,
  each in its own `git worktree` + branch (`--worktree REPODIR`, off `--base-ref`,
  named `<branch-prefix><jobid>`) so concurrent autonomous runs never clobber one
  working tree. `--parallel > 1` requires `--worktree`; worktree add/remove is
  mutex-serialized while the agent work runs concurrently. Branch/worktree/base-ref
  are exported to `--exec` (BUZZ_BRANCH/WORKTREE/BASE_REF) so it commits, pushes the
  branch, and opens the PR. Merge stays on GitHub — never here.
- Group-driven priority: BuzzJobAggregator now folds kind-7 upvotes (distinct
  reactors, dislikes excluded) into JobView.upvotes, and `byPriority` orders the
  backlog most-upvoted-first, oldest-first tiebreak. The stack reprioritizes itself
  as the channel reacts. `buzz job list/show` surface upvotes.
- Channel-as-allowlist: `--accept-from-channel` obeys any member of the channel's
  kind-39002 roster ("anyone in the channel can drive"), union with explicit
  `--accept-from` npubs.
- Harness cli/tests/buzz/job-loop.sh gains a parallel case (3 jobs, --parallel 3,
  one branch/worktree each, cleaned up); aggregator gains upvote + priority tests.
  11/11 harness checks + all unit tests green.

Fits entirely inside amy: amy is the scheduler, the coding agent is whatever
`--exec` points at, GitHub owns merge. Plan doc updated with the model + the
"can this live in Amy" architecture note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 18:19:40 +00:00
Claude 635e785753 feat(cli): buzz agent-job loop — file/track jobs + a driving responder
Prototype the "human drives an AI coding agent to develop Amethyst" support
channel on top of the existing block/buzz integration, all through amy.

- commons: BuzzJobAggregator (BuzzJobs.kt) — a pure, tested folder that
  correlates the Buzz agent-job kinds (43001-43006) by their `e` request
  reference into JobView records with a REQUESTED→ACCEPTED→IN_PROGRESS→
  COMPLETED/FAILED/CANCELLED state machine (newest terminal wins). Shared so a
  future mobile Jobs board reuses one correlation path. 9 unit tests.
- cli: `amy buzz job request|list|show|cancel` (requester side) and
  `amy buzz agent serve --exec CMD` (the responder loop): polls for REQUESTED
  jobs targeting my key, gates intake on `--accept-from` (allowlist) and
  `--channel`, then accepts (43002) → progress (43003) → runs `sh -c CMD`
  (task text on stdin; BUZZ_JOB_ID/REQUESTER/CHANNEL/RELAY/AGENT in env) →
  result (43004) or error (43006). Point `--exec` at a coding agent to drive it.
- cli/tests/buzz/job-loop.sh — self-contained headless harness over an embedded
  `amy serve` relay; asserts the full loop AND the permission gate (an allowlist
  excluding the requester handles nothing).
- Design doc cli/plans/2026-07-25-buzz-agent-support-channel.md: the three-layer
  permission model (Buzz scopes by identity, not capability flags — so
  "can't merge/destroy main" lives in GitHub branch protection + the `--exec`
  credential, not the relay), the MVP architecture, and the prioritized mobile
  app gap list (approvals inbox, jobs board, diff/PR review, …).

Schema caveat: kinds 43001-43006 are reserved in Buzz with no upstream builder;
the tag layout is Quartz's best-effort model, to be reconciled upstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 17:18:30 +00:00
David KasparandGitHub a488c589f5 Merge pull request #3714 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-25 16:44:13 +02:00
vitorpamplonaandgithub-actions[bot] f088dade41 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-25 13:44:35 +00:00
Claude 45517730e3 Merge remote-tracking branch 'origin/main' into claude/buzz-community-redesign-vrovav 2026-07-25 13:42:48 +00:00
Vitor PamplonaandGitHub 683d90763b Merge pull request #3715 from vitorpamplona/claude/relay-groups-card-design-7i9qak
Relay Rail: cluster relay groups by host relay with slim headers
2026-07-25 09:41:26 -04:00
davotoulaandClaude Opus 5 b07f8f203d refactor(chats): drop redundant !! on chat-message edit callback
The `if (canEditBuzz || canEditConcord)` guard already proves
`onWantsToEditChatMessage` non-null — both booleans are local vals whose
definitions begin with a null check, and K2 propagates that through them.
The `!!` compiled to an assertion that could never fire, and produced an
"Unnecessary non-null assertion" compiler warning.

No behaviour change: the smart-cast invoke is what was already happening.
If either guard is later loosened, this now fails at compile time instead
of becoming a runtime NPE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-25 12:44:50 +02:00
Claude 7fe7111b11 feat: modernize Buzz community screens
Redesign the Buzz workspace community view to feel closer to the Concord
server view — richer, more informative channel and DM rows, with actions
tucked behind overflow menus so the list reads cleanly.

- Title bar now uses a middle ellipsis on the community name so both ends
  stay visible instead of truncating the tail.
- Channel cards (BuzzImportRow) now show a last-message preview (author +
  snippet, or the Buzz activity summary for system/diff/job rows), a
  recent-posters facepile, an unread-count badge, and the last-activity
  time — reusing the Concord facepile/unread-badge composables. Each card
  warms its recent messages while visible so previews fill in ahead of a tap.
- Pin/Unpin and Add-to-my-list move off the channel row into a per-channel
  3-dot overflow menu.
- DM rows gain the same last-message preview line.
- Add-all and Agent Console move from the inline header button / footer card
  into the community's top-bar 3-dot overflow menu.
- Add relay-group timeline helpers (newestTimelineNote, recentAuthorHexes,
  relayGroupChannelUnreadCountFlow) mirroring the Concord ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
2026-07-25 05:32:11 +00:00
Claude 560c4fcff5 Revert "refactor: derive relay-group row preview from note cache, not lastNote"
This reverts commit 7b894df433.
2026-07-25 05:10:48 +00:00
Vitor PamplonaandGitHub c64d4eacd2 Merge pull request #3711 from davotoula/fix/flaky-large-cache-addressable-test
Fix GC-dependent flaky test in LargeCacheAddressableFilterTest
2026-07-25 00:47:54 -04:00
Claude 7b894df433 refactor: derive relay-group row preview from note cache, not lastNote
Align the discovery row's last-message preview with the pattern Concord's
list previews use: compute the newest chat message from the channel's note
cache reactively (keyed on the notes flow) instead of reading
Channel.lastNote. For relay groups the two are equivalent today — kind-11
threads live in a separate collection and kind-1111 comments aren't
attached to the group's notes — so this is a consistency/robustness change
that keeps the preview, its timestamp, and the message count all derived
from the same source, not a behavioral fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
2026-07-25 04:44:36 +00:00
Vitor PamplonaandGitHub 9ffac69718 Merge pull request #3713 from vitorpamplona/claude/update-dependencies-kt62lx
Upgrade genaiPrompt to 1.0.0-beta4
2026-07-25 00:27:05 -04:00
Vitor PamplonaandGitHub 0cfbaf73ff Merge pull request #3712 from vitorpamplona/claude/nip29-relay-nav-behavior-tgddmf
Support RelayGroupChannelListScreen as bottom-nav tab
2026-07-25 00:26:35 -04:00
Claude 35914cf241 fix(bottom-bar): make a pinned NIP-29 relay a proper bottom-nav tab
A NIP-29 relay pinned to the bottom bar navigates to Route.RelayGroupServer
(RelayGroupChannelListScreen), but that screen always drew a back arrow and
never rendered a bottom bar — so tapping the pinned relay icon dropped the
bottom nav and showed a back arrow, unlike every other bottom-nav root.

Mirror the norm the analog Concord server screen already follows: read
nav.canPop() once, show the back arrow only when pushed (drawer / another
screen), and add an AppBottomBar keyed to the relay's own route. AppBottomBar
hides itself on a bottom-nav root, so the relay now behaves both ways — a
bottom-nav tab (bar visible, no arrow) when tapped from the bar, and a pushed
detail (arrow, no bar) when opened from the drawer or elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU7StQsshfjcaLDXTBtnB2
2026-07-25 04:24:16 +00:00
Claude 107dfeb1a8 feat: show last-activity time on relay-group rows; correct preview semantics
Add a compact "2h"/"3d" last-activity timestamp to each discovery row's
name line, so the preview reads as recent activity with a clear recency
signal rather than an implied live feed.

On the discovery screen most groups are ones you haven't joined, and the
app's always-on live chat tail is joined-groups-only — a non-member isn't
streamed a group's new messages — so the last-message line is a snapshot
of recent public activity that fills in and refreshes as the row loads,
not a real-time ticker. Reword the code comment to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
2026-07-25 04:15:42 +00:00
Claude 364d0d1fde chore(deps): bump ML Kit genai-prompt to 1.0.0-beta4
Update the on-device GenAI prompt dependency (play flavor only) from
1.0.0-beta3 to 1.0.0-beta4, the latest in-track release.

All other catalog entries are already at their latest stable versions.
appfunctions could not move to alpha10 because appfunctions-service
only publishes up to alpha09 and the three artifacts share one version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZJ8ekaDDihWWhcJ6X2Fjz
2026-07-25 04:15:31 +00:00
davotoula f662b131c8 Code review:
- make the note list the fixture's source of truth
- trim the GC regression test
2026-07-25 06:10:08 +02:00
Claude 0611c3866d fix: make relay-group message count a neutral stat, not a violet pill
The message-activity badge used the accent (violet) color, which reads as
an unread indicator elsewhere in the app. Render it as a muted
onSurfaceVariant chat-icon + count instead, so it clearly signals
activity volume rather than unread messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
2026-07-25 03:54:28 +00:00
davotoula 94c50946e4 test(cache): add GC-forcing regression for LargeCacheAddressableFilterTest
fix(cache): keep LargeCacheAddressableFilterTest mocks strongly reachable

LargeSoftCache stores values as WeakReferences, so the cache alone does
not keep the mock AddressableNotes alive. Hold each note in a companion
strongRefs list for the lifetime of the test class, so a GC between
class-load and the read can no longer clear them.
2026-07-25 05:46:12 +02:00
Claude b5d64f6527 Merge remote-tracking branch 'origin/main' into claude/relay-groups-card-design-7i9qak 2026-07-25 03:29:38 +00:00
Vitor PamplonaandGitHub 8c046df330 Merge pull request #3697 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-24 23:28:55 -04:00
Vitor PamplonaandGitHub 52d5bc93c9 Merge pull request #3709 from vitorpamplona/claude/nip01-multirelaypool-test-failure-dtl9qp
Fix race condition in NIP-01 compliance test with concurrent relays
2026-07-24 23:28:36 -04:00
Vitor PamplonaandGitHub 5244a4a643 Merge pull request #3707 from vitorpamplona/claude/message-screen-channel-rendering-f8zkvd
Extract chat room mark-as-read logic into reusable function
2026-07-24 23:28:09 -04:00
Vitor PamplonaandGitHub 90dc9a874e Merge pull request #3685 from vitorpamplona/claude/nip-2421-pr-review-6znvdd
Add NIP-XX BOLT12 zap support with validation and UI integration
2026-07-24 23:26:28 -04:00
vitorpamplonaandgithub-actions[bot] 72aac98ff1 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-25 03:01:55 +00:00
Claude 9caf330879 fix(bolt12): don't throw on an oversized tu64; cover proof_note path
Audit of the compressed-proof work found one real defect and one coverage gap.

Defect: a hostile BOLT12 proof/offer can carry a 9+ byte `invoice_amount`
(or any tu64 field) that parses as a valid TLV. `TlvStream.tu64` then called
the strict `Bolt12Values.tu64`, which throws `require(size <= 8)`. On the
`amy bolt12 verify` path (`Bolt12ZapActions.validate`, no surrounding catch)
that surfaced as an uncaught exception and abnormal exit instead of a clean
`Invalid`; the Android ingest path was already contained by LocalCache's broad
catch. Make the nullable stream accessor `TlvStream.tu64` return null for an
over-8-byte value so every amount read (invoice_amount, invreq_amount, offer
amount) degrades to a clean rejection. Regression-tested at the codec level.

Coverage: the writer's `proof_note` (1005) branch and the `with_note` vector's
note were never exercised. Add a `Bolt12PayerProof.proofNote()` reader and
thread the vector's note through the writer round-trip so 1005 is asserted.

The forged-proof, DoS, and reconstruction-accounting paths were reviewed and
found sound (the reconstructed root is only ever a BIP-340 message; the NIP
offer-binding gate still pins invoice_node_id to the offer's issuer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 03:00:51 +00:00
Vitor PamplonaandGitHub 164e9975d7 Merge pull request #3708 from vitorpamplona/claude/blossom-mirror-api-support-q2h9t1
Add mirrorOrUpload fallback when Blossom /mirror endpoint unsupported
2026-07-24 22:59:01 -04:00
Claude cd221d5de1 feat: cap Messages room-label chips at ~half the row, unify Concord chip color
The type/label chips that sit beside a room name on the Messages screen — the
NIP-28 "Public Chat" pill (HeaderPill), the NIP-29 relay-host chip
(RelayNameChip), and the Concord community chip (ConcordCommunityPill) — could
grow with a long relay URL or community name and crowd the room name out.

Cap each at ChatLabelMaxWidth (140.dp, ~half a phone row) via widthIn(max); the
room name stays weighted so it keeps whatever the capped chip doesn't take, and
each chip's label truncates with a middle ellipsis (TextOverflow.MiddleEllipsis)
so the informative head and tail both survive. RelayNameChip switches from a
plain end ellipsis; ConcordCommunityPill drops its char-count truncation
(maxChars) for width-based truncation.

Also give the Concord chip the NIP-29 chip's highlighted look — secondaryContainer
background / onSecondaryContainer content (a gray on the dark theme) — so both
"which server/community does this room belong to" chips read the same.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168TWLTgrMxUR6yjjCCiBLS
2026-07-25 02:47:02 +00:00
Claude d32da653d3 fix(geode): make multi-relay compliance test thread-safe
multiRelayPoolReturnsContentFromEachRelay flaked with
"expected:<from-b> but was:<null>": the SubscriptionListener wrote the
per-relay results into a plain HashMap/HashSet, but each relay delivers
its EVENT/EOSE on its own InProcessWebSocket scope (Dispatchers.Default)
and PoolRequests dispatches the listener callbacks outside any lock. Two
relays therefore call `received[relay] = ...` concurrently, and a
HashMap.put racing a rehash can drop an entry, leaving a relay's value
null and failing the assertion.

Use ConcurrentHashMap and ConcurrentHashMap.newKeySet() for the shared
collections. Reproduced within 7 runs before the fix; 80 stress runs
clean after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeAjLDNBvGPjb5bfViU3ad
2026-07-25 02:42:36 +00:00
Claude 7535d791f3 feat(bolt12): verify compressed payer proofs via merkle reconstruction
Real BOLT12 wallets emit selective-disclosure payer proofs: `invreq_metadata`
is always withheld and other invoice fields may be elided for privacy, with
`proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` carrying
enough to rebuild the invoice signature's merkle root. The verifier previously
reported these as unsupported (cryptoVerified = false), so a zap paid through a
real wallet never counted locally.

Implement the lightning/bolts#1346 reader:

- Bolt12Merkle.reconstructRoot rebuilds the invoice root from the disclosed
  LnLeaf hashes + supplied nonce leaves (proof_leaf_hashes) + omitted-field
  markers + missing subtree hashes (consumed post-order DFS, smallest-to-largest).
  Add emitMissingHashes as the writer dual, unify both on one tree builder.
- Fix two latent interop bugs the vectors exposed: the nonce leaf hashes the
  record's type bytes (not the full encoded TLV), and the payer proof signs
  under fieldname `proof_signature` (not `signature`).
- Bolt12PayerProof gains marker/leaf/missing accessors and the invoice-field
  range predicate; the verifier reconstructs on every proof (type 0 is always
  the implied first omitted leaf) and drops the Unsupported result.
- Add Bolt12ProofBuilder to mint spec-compliant proofs (tests + future interop),
  and rewire Bolt12ProofFixture onto it.

Validated byte-for-byte against the draft's own conformance suite
(bolt12/payer-proof-test.json): all 5 valid vectors verify, all 23 invalid are
rejected, and the writer reproduces every vector's compression fields exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 02:35:16 +00:00
Claude 039beb0684 fix: mark all room types read in Messages "mark as read"
markAllChatNotesAsRead only enumerated public chats (IsInPublicChatChannel)
and DMs (ChatroomKeyable), so every newer room type fell through the when()
with no branch: NIP-29 relay groups, Concord communities, Marmot groups,
geohash chat, and ephemeral relay chat. Their unread dots on the Messages
screen could only be cleared by opening each room — "mark all as read" left
them lit. The collapsed per-server rows (RelayGroupServerRoomNote,
ConcordServerRoomNote) were skipped too.

Extract markRoomNoteAsRead(account, note), mirroring ChatroomEntry's type
dispatch so each row's last-read route is resolved the same way its unread
dot reads it: synthetic grouped rows first (fanning out to every joined
group on the relay / every channel in the community), then gatherer-attached
channels (Marmot, NIP-29, Concord, geohash), then the h-tag group fallback,
then the raw event type (public chat incl. ChannelCreateEvent, ephemeral,
DM, and drafts wrapping those). markAllChatNotesAsRead now just maps the
visible notes through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
2026-07-25 02:29:34 +00:00
Claude 4fe60162e1 feat: smaller subtitle font in Messages screen rows
The message-preview second line on every Messages-screen row (channels,
groups, and DMs) rendered at the ambient bodyLarge (16sp), matching the
bold title above it. Drop it to bodyMedium (14sp) so the title and the
muted preview read as two tiers instead of one block of same-size text.

Covers both renderers all rows funnel through: ChannelName (public
chats, ephemeral/geohash chats, Marmot/NIP-29 groups, Concord) and
LastMessagePreview (NIP-17/NIP-04 DMs), including the
"event not found" fallback line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
2026-07-25 02:20:09 +00:00
Vitor PamplonaandGitHub cdc6efd05f Merge pull request #3705 from vitorpamplona/fix/concord-control-plane-starvation
fix(concord): isolate the Control Plane sub so channels don't starve
2026-07-24 21:57:50 -04:00
Vitor PamplonaandGitHub bd1a6dbf2d Merge pull request #3706 from vitorpamplona/claude/bottom-nav-relay-community-nswtrf
Add pinnable relay servers and Concord channels to bottom bar
2026-07-24 21:57:11 -04:00
Vitor PamplonaandClaude Opus 4.8 844b0e9803 fix(concord): isolate the Control Plane sub so channels don't starve
A Concord community pinned to the bottom bar folded only a fraction of its
channels — Soapbox showed 1 of 12. The live subscription collapsed a
community's Control + Guestbook + rekey + every channel plane into ONE
kind-1059 filter per relay, and the channel list is folded from the Control
Plane. On an AUTH-gated relay that caps a REQ per filter (measured ~100
events/filter on relay.dreamith.to), the chatty Guestbook plane crowded the
channel-defining control editions out of the cap, so only a fraction of the
channels folded. On the strict relay.ditto.pub the collapsed multi-author
filter is refused wholesale until every author is authenticated.

- Split the Control Plane into its OWN filter, apart from the Guestbook /
  rekey / channel planes (ConcordSubscriptionPlanner.controlIsolatedFilters),
  so it gets an isolated per-filter budget. Both filters still ride the same
  per-relay REQ (no extra socket).
- Add a COMPLETE-mode Control-Plane sweep (Account.syncConcordControlPlanes):
  re-fetch the whole plane with no `since`, paging past the per-filter cap via
  fetchAllPagesFromPool, so a forward cursor can never hide an edition and the
  cap can never truncate the fold. Mounted account-wide, fired on load +
  membership/held-epoch change + relay reconnect (not a wall-clock poll — the
  persistent live subscription keeps a connected relay complete).

Mirrors Armada's plane-sweep design (one filter per plane scope, COMPLETE-mode
control). Verified on-device: Soapbox now folds all 12 channels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:47:11 -04:00
Vitor PamplonaandGitHub 122321cd94 Merge pull request #3704 from vitorpamplona/claude/buzz-community-star-add-buttons-knx2ry
Support Buzz stream messages in group chat and improve chat previews
2026-07-24 21:46:58 -04:00
Claude f62ecb7ad2 fix(nwc): kotlinx pay/receive parsers mishandle explicit JSON null
Cross-backend defects in the kotlinx (native/iOS) NWC-321 parsers, found by the
audit and empirically reproduced. Jackson (JVM/Android) writes null-valued keys,
so a native peer parsing that output hit two bugs:

- parsePay/parseReceive crashed on `metadata: null` — `?.jsonObject` doesn't
  short-circuit on JsonNull (a non-null element). Use `as? JsonObject`.
- parsePaySuccess/parseReceiveSuccess (and parsePay's string fields) read an
  explicit JSON null as the literal string "null" via `?.jsonPrimitive?.content`.
  Use `contentOrNull`.

Only affects the kotlinx path (Android/JVM use Jackson), but violates the KMP
mapper-interchangeability contract. Adds Nip47KotlinSerializationNullTest hitting
the kotlinx serializers directly so it's covered regardless of platform actual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 01:27:49 +00:00
Claude 42e69fd424 fix(bolt12): audit fixes — verify crash, decode hardening, send robustness
- amy bolt12 verify: the id-only query with a <Bolt12ZapEvent> type crashed
  with ClassCastException when the id pointed at a non-9736 event. Constrain
  the filter to kind 9736 and cast defensively (query<Event>() as?), returning
  a clean not_found instead.
- Bolt12ZapActions.decodeOffer/decodeProof: a parseable bech32 with an
  over-8-byte amount TLV threw in tu64 on field read instead of honoring the
  null contract; guarded the field extraction in runCatching. Adds a
  malformed-amount regression test.
- Account.sendBolt12Zap: wrap the NWC response callback in try/catch/finally so
  a post-payment receipt-assembly failure (e.g. a remote signer error) steps
  progress and surfaces "paid, no receipt" instead of vanishing as an uncaught
  coroutine exception.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 01:23:23 +00:00
Claude 107ff18366 fix(buzz): count all Buzz chat-timeline kinds as a room's last message
Widen the fix beyond the plain stream chat message: every Buzz kind the chat
feed renders as a row — stream messages (40002), system lines (40099), diffs
(40008), and the agent-job (43001-43006) and huddle (48100-48103) lifecycle
events — is `h`-scoped and attaches to the same RelayGroupChannel as a kind-9
via consumeBuzzTimelineEvent. So all of them must count as a room's newest
message and toward its unread dot; leaving them out left the Messages-list
preview stale whenever the newest thing in a channel was one of these.

- Add `Event.isBuzzChatTimelineContent()` (quartz buzz) enumerating exactly the
  kinds consumeBuzzTimelineEvent attaches / the chat renders — excluding edits
  (folded into their target), canvas, and forum kinds. `isGroupChatContent()`
  now ORs it in, so the initial scan, the live additive update, and the unread
  dot all agree.
- These kinds carry JSON/diff in `content`, so previewing raw `content` would
  dump `{"ephemeral_channel_id":…}`. Extract the in-chat labels into pure
  helpers (`buzzSystemMessageText`, `buzzActivityLabel`,
  `buzzTimelinePreviewSummary`) so the Messages-list preview shows the same
  human-readable summary the chat row shows ("🔊 huddle started", "topic
  changed", "⚙ job progress: …", "📄 <file>") instead of raw payload.
- Extend the regression test to cover stream/system/huddle counting and edit/
  reaction not counting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
2026-07-25 00:54:22 +00:00
Claude 9bb6237d8c docs(bolt12): note the nwc#2 ecosystem status (LND service exists, BOLT12 gap)
benthecarman/nostr-wallet-connect-lnd implements NWC-321 pay/receive (confirming
Phase 0), but is LND-backed so it rejects BOLT12 lno and returns no payer_proof.
The blocker for real BOLT12-zap testing is a CLN/LDK-backed NWC-321 service.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 00:51:38 +00:00
Claude 73859f4e04 feat(bottom-bar): pin a whole NIP-29 relay or a single Concord channel
The bottom-bar settings picker only exposed one level of each grouped chat
system: NIP-29 let you pin an individual group but not the host relay, while
Concord let you pin a community but not an individual channel. Since a NIP-29
relay is the analog of a Concord community (the container) and a Concord
channel is the analog of a NIP-29 group (the item), both systems now offer
both levels.

- Add BottomBarEntry.RelayServer(relayUrl) -> Route.RelayGroupServer (the
  relay's home page of all joined groups) and BottomBarEntry.ConcordChannel(
  communityId, channelId, relays) -> Route.Concord (a specific channel), with
  stable @SerialName discriminators and stableKeys.
- Resolve their live avatar/label/route: the relay via its cached NIP-11 doc,
  the channel via the community session's folded Control Plane (community icon
  + channel name).
- Regroup the picker by container: each relay/community is an addable "server"
  row with its groups/channels nested beneath it, so you can add the whole
  server or a single room in both systems.
- Bootstrap a pinned channel's community list (importConcordCommunities and the
  pinned-community preloader now also read ConcordChannel entries).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012v531djZmQBxVoyCm45BNY
2026-07-25 00:43:19 +00:00
Claude affc547b0e fix(buzz): update Messages-list preview + unread dot for Buzz stream channels
A Buzz stream-channel chat message is a kind-40002 StreamMessageV2Event, not a
NIP-C7 kind-9 ChatEvent. It is `h`-scoped and attaches to the same
RelayGroupChannel as a kind-9, but `isGroupChatContent()` only recognized
ChatEvent/PollEvent/ThreadEvent/CommentEvent, so the Messages-list "newest
message" logic (initial scan `newestChatNote` + the live additive
`filterRelevantRelayGroupMessages`) and the unread-dot check all skipped it.
Result: a Buzz channel's row never reflected its real chat and never updated
live as new messages arrived.

Include StreamMessageV2Event in `isGroupChatContent()` (the Buzz dialect of
NIP-29), fixing the Messages preview and unread dot in one place. Adds a
regression test asserting kind-40002 counts as group chat content while a
group-scoped reaction does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
2026-07-25 00:38:34 +00:00
Claude 57f95cf154 feat(cli): add amy bolt12 — decode, verify, offers, and two-step send
Adds a BOLT12 zap (NIP-XX) command group over a new shared commons
Bolt12ZapActions (assembly-only, mirrors ZapActions):

  bolt12 decode LNO1|LNP1        decode an offer or payer proof
  bolt12 verify EVENT-ID         validate a kind:9736 in the local store
  bolt12 offer get/set           read/publish a kind:10058 offer list
  bolt12 intent … / zap …        two-step out-of-band send (amy has no NWC
                                  rail): intent prints the payer_note; zap
                                  wraps the signed intent + settled proof
                                  into a validated kind:9736 and publishes

Keeps cli a thin assembly layer — all logic is quartz's Bolt12ZapBuilder/
Validator/codecs via commons Bolt12ZapActions. Adds Bolt12ZapActionsTest;
updates README + ROADMAP. Interop harness and NWC-fetched proofs remain TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 00:22:42 +00:00
Claude 31a0137924 feat: redesign Relay Groups discovery as a relay-grouped rail
Rework the NIP-29 Relay Groups discovery feed from a flat list of tall
ElevatedCards into the "Relay Rail" layout: groups are clustered under a
slim per-relay header (NIP-11 icon + name, group count, favorite star)
and rendered as thin inbox-style rows inside a rounded block.

Each row now previews what's actually happening in the group instead of
a static blurb: the newest message as "author: text", a green LIVE pill
when the group has an active audio room (hasLivekit), the people you
follow who are already inside, or a compact message-activity badge. The
old `about` description and the standalone Join button are dropped to
keep rows thin — tapping a row opens the group where you can join.

The feed is bucketed by host relay in the composable (a cache lookup, no
extra subscription); per-row metadata and the message preview still
stream lazily as rows scroll on, via the existing warm-up subscription.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
2026-07-25 00:14:53 +00:00
Claude 9ca79ec2c6 feat: pin icon for Buzz channels, fix Added padding, show "You:" in Messages
- Swap the Buzz community channel favorite from a Star to a PushPin icon
  (and rename the buzz_star/buzz_unstar strings to buzz_pin/buzz_unpin),
  since the action only pins a channel to the top of the list — it never
  publishes anything, unlike Add which imports into the kind-10009 list.
- Give the "Added" state in BuzzImportRow trailing padding so its label no
  longer jams against the row edge when the Add button flips to Added.
- Prefix a Messages-list DM preview with "You:" when the newest message was
  sent by the logged-in user, so a room shows who spoke last.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
2026-07-24 23:50:40 +00:00
Claude 79fee9492e Merge remote-tracking branch 'origin/main' into claude/nip-2421-pr-review-6znvdd
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt
2026-07-24 23:50:40 +00:00
Vitor PamplonaandGitHub b4e8719ee2 Merge pull request #3701 from vitorpamplona/claude/nip47-spec-compliance-1c9ook
Add NWC payment notifications and deep-link pairing
2026-07-24 19:43:01 -04:00
Claude 7d9597b91f feat(bolt12): gate BOLT12 zaps on wallet pay support, fall back to lightning
Phase 3 capability gating. NwcSignerState caches the default wallet's advertised
NIP-47 methods; Account refetches them via nwc#2 get_info whenever the default
wallet changes. A zap now prefers the BOLT12 pay rail only when the wallet
advertises `pay` (Account.defaultWalletSupportsBolt12Pay) — otherwise the
recipient falls back to lightning through the existing partition, so a wallet
without pay support degrades gracefully instead of erroring. The profile
"pay with wallet" action is gated on the same signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 23:21:06 +00:00
Claude 40820db10f refactor(wallet): move NWC notification subscription into the always-on account subscription layer
The bespoke applicationIOScope watcher that opened its own relay
subscription for NIP-47 wallet notifications is replaced by an EOSE
manager registered in AccountFilterAssembler.group — the same always-on
scheme as the account's zap/notification inbox subscriptions. It is now
owned by the single AccountFilterAssemblerSubscription in LoggedInPage,
kept warm in the background by NotificationRelayService, and torn down on
logout — matching zap-receipt lifecycle exactly (and not gated on OS
notification permission).

- NwcNotificationsEoseManager (PerUserEoseManager): one filter per
  connected wallet's own relay (kind 23197/23196, #p = per-wallet client
  pubkey), re-invalidating when the wallet set changes, `since`-floored at
  watch start, deduped by a seen set. Because these events are ephemeral,
  encrypted, and never land in LocalCache, onEvent decrypts them via
  NwcSignerState.handleIncomingNotification.
- NwcSignerState.handleIncomingNotification decrypts with the matching
  wallet's connection secret, drops zap-carrying payments, and publishes
  non-zap payments to a new incomingNonZapPayments SharedFlow — a clean
  seam a future in-app Notifications-tab consumer can also drain.
- NwcPaymentNotificationWatcher is now just the Context-bound bridge that
  drains that flow into an OS tray notification (no relay work, no client
  dependency).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
2026-07-24 23:17:15 +00:00
Claude ec4928fc0d Merge remote-tracking branch 'origin/main' into claude/nip-2421-pr-review-6znvdd
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-07-24 23:15:54 +00:00
Vitor PamplonaandGitHub 09e389373c Merge pull request #3703 from vitorpamplona/claude/blossom-file-import-xwcrzn
Add Blossom blob import flow to pull files from other servers
2026-07-24 19:01:04 -04:00
Claude 16c6acd78a fix(blossom): auto-dismiss the finished sync/import banner
The app-level "sync all" / import progress banner switched to "Sync
complete" when the sweep finished but then lingered until the user
tapped X. Auto-dismiss it a few seconds after completion, keeping the X
for dismissing early. Keyed on the running flag so a new sweep cancels
the pending dismiss and tapping X re-keys it to a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
2026-07-24 22:51:46 +00:00
Claude eced3c4027 fix(bolt12): honor NONZAP zaps as a receiptless BOLT12 payment
A NONZAP (pay-without-receipt) zap must not publish a public 9736. sendBolt12Zap
now settles the offer over NWC without binding an intent or emitting a receipt
when the zap type is NONZAP, matching bolt11 NONZAP privacy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 22:50:03 +00:00
Claude 26272a6c3b feat(bolt12): send BOLT12 zaps over NWC, preferred when offered
Integrates BOLT12 zap-sending into the zap pipeline (nwc#2 `pay` returns the
payer proof). Account.sendBolt12Zap signs a 9737 intent, pays the offer over
NWC with the intent-bound payer_note, and — only if the returned proof passes
Bolt12ZapValidator — self-consumes and publishes the 9736; otherwise reports
"paid, no receipt" (fail-safe against a wallet that misroutes the note).

ZapPaymentHandler.zap now resolves each recipient's kind:10058 offer and
partitions recipients into a BOLT12 lane (offer present + NWC wallet configured)
and the existing lightning lane, sharing split weight across both so mixed
splits stay proportional. Anonymous/public follows the account zap type. Adds
Bolt12ZapBuilderTest proving the send-side assembly round-trips to a
validator-accepted, crypto-verified zap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 22:48:36 +00:00
Claude 895bcc3381 refactor(blossom): harden import flow after audit
Follow-up fixes from an audit of the import feature:

- Cancel an in-flight scan when the source selection changes
  (toggle/add/remove/enable-all). Without this a scan started against
  the old selection could land afterwards and offer blobs sourced from a
  server the user just de-selected — which importSelected() would then
  mirror from.
- Sign the BUD-02 list token once per scan and reuse it across every
  source and target. The token carries no `server` scope tag, so it's
  valid everywhere; per-server signing was a round-trip storm with
  remote NIP-46 signers.
- BlossomMirrorQueue.start() now returns whether it actually started a
  sweep. importSelected() keys the Started/Busy result off that instead
  of a separate isRunning check, closing a TOCTOU where the import would
  report "started" but the queue silently dropped the work.
- The import screen's empty-state now collects the kind-10063 server
  list reactively, so the "add servers first" ↔ picker switch recomposes
  when the list arrives from a relay after the screen opens.
- init() re-points at the current account each call (matching the
  sibling BlobManager VM) while still seeding the source list only once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
2026-07-24 22:47:52 +00:00
Vitor PamplonaandGitHub eddffdbd3e Merge pull request #3702 from vitorpamplona/claude/ots-notes-lifecycle-ok3mz3
Anchor OTS attestations to target notes, replace verification cache
2026-07-24 18:44:29 -04:00
Claude fef1d15f12 fix(ots): never persist the non-terminal Verifying sentinel on the note
verifyOts wrote otsVerification = Verifying before the suspending blockchain
verifyState() call and only overwrote it with the real verdict afterwards. That
verification runs inside LoadOts's cancellable LaunchedEffect, so a row scrolling
off-screen cancels the coroutine at the network suspension point — the verdict
write never happens and the note is left stuck at Verifying. cacheVerifyOts
treats Verifying as terminal, so earliestOtsVerifiedTime then returns null and the
confirmed-timestamp pill silently vanishes for the note's lifetime. The old
VerificationStateCache had the same write but its LRU eventually evicted the stuck
entry; anchoring the verdict on the long-lived note removed that recovery.

Store only terminal verdicts (Verified / Error / NetworkError). A cancelled or
in-flight verification leaves the field null and simply retries on the next read;
the cost is at worst two concurrent first-time verifications, already benign.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
2026-07-24 22:42:51 +00:00
Claude 9789ff61f4 fix(nip47): parse space-separated encryption/notification tags; drop accumulating notification subscription
Two issues found in an audit of the NWC changes:

1. (correctness, high) NIP-44 negotiation never triggered against real
   wallets. The info event carries schemes in a single space-separated
   tag value (["encryption", "nip44_v2 nip04"]), but encryptionSchemes()
   returned tag.drop(1) = ["nip44_v2 nip04"], so the nip44_v2 membership
   check never matched and every request fell back to NIP-04. Split each
   tag value on whitespace in encryptionSchemes()/notificationTypes() so
   both the spec's space-separated form and a multi-element tag normalize
   to individual tokens. Adds NwcInfoEvent tests for the wire format.

2. (performance) NwcPaymentNotificationWatcher subscribed via
   subscribeAsFlow, which accumulates every event into an ever-growing
   list and re-emits the whole list per event — wrong for a lifetime
   subscription (unbounded retention + O(n) rescan per event). Replace
   with a raw client.subscribe listener (callbackFlow) that emits each
   event once; reconnect re-delivery is still de-duped by the seen set.

Also documents why the watcher keys the account flow on pubkey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
2026-07-24 22:42:12 +00:00
Vitor PamplonaandGitHub 8e2846b3f5 Merge pull request #3700 from vitorpamplona/claude/chat-picture-sending-consistency-qit0pz
Add image attachment support to minichat threads
2026-07-24 18:39:30 -04:00
Claude c7415f1559 docs(bolt12): record nwc#2 resolution — Phase 2 unblocked
Maintainer confirmed payer_note maps to invreq_payer_note for BOLT12 and
payer_proof is returned for successful BOLT12 payments. Notes the
validate-before-publish fail-safe so a non-conforming wallet never yields an
invalid receipt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 22:29:30 +00:00
Claude a4329912aa refactor(wallet): share a TTL'd NWC info-event cache across features
Replace the single-purpose per-wallet "supports nip44" boolean with a
shared NwcInfoCache that stores each wallet's full kind 13194 info event
(capabilities + encryption schemes + notification support), keyed by
wallet pubkey and owned by Account.

- Entries expire after 2 days so a wallet that changes its advertised
  capabilities is eventually re-checked. Reads never block: the payment
  path reads the cached value and nudges a background refresh when the
  entry is missing or stale (self-healing without holding up the tx);
  failed fetches are not cached, so a transient error retries next use.
- NwcSignerState derives the NIP-44 preference from the cache.
- NwcPaymentNotificationWatcher now consults supportsNotifications() and
  skips opening a relay subscription for wallets that advertise none
  (fail-open when the info event is unknown).

Adds NwcInfoCacheTest covering caching, TTL expiry, no-cache-on-failure,
and definitive-missing-info caching (injectable clock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
2026-07-24 22:19:17 +00:00
Claude a65d2b9342 refactor(ots): anchor OTS attestations on the Note lifecycle, drop the verification cache
NIP-03 OpenTimestamps attestations (kind 1040) were stored loosely in the main
note cache and found via a full-cache scan, with their blockchain verdicts held
in a separate, id-keyed VerificationStateCache LRU. Nothing tied either to the
lifecycle of the note being timestamped, so a deleted/pruned note leaked its
attestations, and consume(OtsEvent) invalidated the attestation's own
(observer-less) flow instead of the target's — so a live-arriving proof never
pinged the target's UI.

Mirror the recent edits→Note migration:

- Note gains a `timestamps` child collection (like `edits`/`reactions`), wired
  into clearChildLinks/removeNote, so an attestation survives exactly as long as
  its target and is collected when the target is pruned or deleted.
- consume(OtsEvent) anchors the proof on its target via the `e` tag and
  invalidates the target's `ots` flow; unlinkAndRemove detaches it symmetrically.
- Each attestation memoizes its own verdict in `Note.otsVerification`, so the
  result shares the note's lifecycle. This replaces VerificationStateCache
  (deleted) and the full-cache scan: the OTS pill now folds `note.timestamps`
  via the new Note.earliestOtsVerifiedTime / cacheVerifyOts helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
2026-07-24 22:18:27 +00:00
Claude 6ce61f0dc8 Merge remote-tracking branch 'origin/main' into claude/chat-picture-sending-consistency-qit0pz
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt
2026-07-24 22:05:36 +00:00
Claude f4ba3b3581 Merge remote-tracking branch 'origin/main' into claude/blossom-file-import-xwcrzn
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt
2026-07-24 21:59:51 +00:00
Vitor PamplonaandGitHub 89cbba42a5 Merge pull request #3699 from vitorpamplona/claude/blossom-files-gallery-re25b4
Redesign Blossom blob manager UI with gallery grid and full-screen viewer
2026-07-24 17:49:15 -04:00
Claude d58bca4177 feat(bolt12): pay a recipient's offer in-app over NWC
Adds a "pay with connected wallet" action to the BOLT12 offer dialog on a
profile: when the account has a NIP-47 wallet configured, an amount sheet
collects sats and settles the offer over the wallet via the nwc#2 `pay`
method (BIP321 bitcoin:?lno=). Falls back to the external bitcoin: intent
otherwise. Outcome is surfaced as a toast. This is a plain payment, not a
NIP-XX zap (no receipt) — that's the deferred Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 21:45:56 +00:00
Claude 7d3f7f7ca4 feat(nwc): add pay/receive methods for BOLT12 (nostr-wallet-connect/nwc#2)
Adds the generalized `pay` (BIP321 payment instruction, incl. BOLT12 `lno=`)
and `receive` NIP-47 methods, plus the UNSUPPORTED_PAYMENT_INSTRUCTION /
UNSUPPORTED_NETWORK error codes. The `pay` result carries `payer_proof`
(lnp1…) — the proof a kind:9736 BOLT12 zap needs. Wired through both
serialization backends (kotlinx + Jackson) with round-trip tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 21:38:32 +00:00
Claude 6f40d997c6 feat(minichat): allow sending pictures in thread replies
Every chat composer in Amethyst already had a picture/media attach button
(SelectFromGallery) except the minichat "thread" screen — the kind-1111
reply composer opened from the "N replies" chip — which was text-only. This
brings it to parity with every other chat.

- quartz: ChannelChat.imageReply() — a kind-1111 thread reply carrying
  encrypted image imeta(s), combining reply()'s NIP-22 pointers with
  imageMessage()'s ciphertext-URL/imeta handling (+ round-trip test).
- commons: ConcordActions.buildChannelImageReply().
- Account.sendMinichatReply() now accepts imetas and routes per backend:
  Concord sends an encrypted image reply; NIP-28/NIP-29 public chats append
  the URL to the content and carry a plaintext imeta on the comment; Buzz
  appends the URL to the stream message content.
- Extract toConcordImeta()/toPlainImetas() into a shared UploadImetas.kt so
  the minichat and Concord composers build imeta the same way.
- MinichatScreen: add the SelectFromGallery leading icon + ChatFileUpload
  dialog, encrypting only when the backend is end-to-end (Concord).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8FD5xm8nyEKzd8dk9VfT1
2026-07-24 21:36:05 +00:00
Claude cd82d7ffff feat(wallet): prefer NIP-44 for NWC and notify non-zap payments
NIP-44 encryption preference: NwcSignerState now fetches each wallet's
kind 13194 info event (via the relay client, injected by Account),
caches whether it advertises `nip44_v2`, and sends pay/RPC requests
with NIP-44 when supported — satisfying NIP-47's "client should always
prefer nip44 if supported by the wallet service". Falls back to NIP-04
(the legacy default) when unknown or unsupported; response decryption
already auto-detects the scheme.

Non-zap payment notifications: NwcPaymentNotificationWatcher keeps a
standing subscription to each connected wallet's NIP-47 notification
stream (kind 23197/23196) on the wallet relay, decrypts payment_received
events, and posts a tray notification on a new Payments Received channel.
Payments carrying a NIP-57 zap request are skipped, since those already
surface through the kind-9735 ZapNotification path — so only plain,
non-zap incoming payments are announced.

Deep-link pairing: the "Connect wallet via app" button now builds its
`nostrnwc://connect` URI through the tested quartz Nip47DeepLink helper
instead of a hardcoded percent-encoded string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
2026-07-24 21:34:55 +00:00
Claude c2f6aa9992 feat(nip47): add NWC-07 deep-link helper and NIP-44 request opt-in
Add Nip47DeepLink for the NWC-07 same-device pairing convention:
build/parse the `nostrnwc://connect` request (client -> wallet) and
the callback URI that returns the `nostr+walletconnect://` pairing code
(wallet -> client). All params are URI-encoded per the spec.

Also thread `useNip44` through LnZapPaymentRequestEvent.create so
pay_invoice requests can opt into NIP-44, matching createRequest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
2026-07-24 21:34:32 +00:00
Claude ccd991c847 feat: video thumbnails + full-screen zoomable viewer for Blossom files
Videos in the gallery now show a decoded first frame (Coil
VideoFrameDecoder) with a play badge instead of a generic glyph; the same
preview is reused in the detail header, with a type-glyph fallback for
blobs Coil can't decode (e.g. HLS playlists).

Tapping an image or video tile now opens a full-screen viewer: images are
zoomable/pannable (engawapg zoomable, matching ZoomableImageDialog) and
videos play inline. Its top bar carries back, a new share action (system
share sheet for the blob URL), and a button that reveals the file's
storage matrix and sync/copy/open/share/report/delete actions in a bottom
drawer. Non-visual blobs (PDFs, arbitrary files) still open straight to
that action sheet. The actions list is now a shared BlobActionsContent so
the sheet and the viewer drawer stay identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
2026-07-24 21:26:35 +00:00
Claude 84a85ffaf7 feat(blossom): import files from other Blossom servers
On the "My Blossom Files" screen, replace the top-bar sync icon with a
3-dot overflow menu offering "Refresh" and "Import files…".

The new Import flow lets the user pull files they've uploaded to other
Blossom servers into their own. They pick source servers to check —
seeded from the recommended bootstrap list (minus servers already in
their own list, with an enable-all toggle) and/or hand-typed addresses.
A scan fans a BUD-02 GET /list/<pubkey> across the enabled sources,
works out which of those blobs are missing from the user's own
kind-10063 servers, and hands the gaps to the existing app-level
BlossomMirrorQueue so each server fetches them via BUD-04 mirror —
reusing the same floating progress banner as the on-screen sync.

- BlossomImportViewModel: source list management, scan, gap detection,
  mirror hand-off.
- BlossomImportScreen: server picker, custom-URL field, scan + results.
- New Route.ImportBlossomBlobs wired into AppNavigation.
- Strings + plurals for the import UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
2026-07-24 21:22:04 +00:00
Vitor PamplonaandGitHub f8f3631a15 Merge pull request #3698 from vitorpamplona/claude/chat-updates-concord-75jx1g
Anchor message edits to their targets instead of workspace state
2026-07-24 17:08:00 -04:00
Claude 802652dd4f docs(bolt12): scope NWC BOLT12 pay/receive (nwc#2) integration
Captures the design for paying BOLT12 offers over NIP-47 and — via the pay
result's payer_proof — sending real kind:9736 zaps. Maps the reusable NIP-47
plumbing, breaks the work into phases, and flags the linchpin risk: nwc#2 does
not guarantee payer_note lands in the BOLT12 invreq_payer_note that NIP-2421's
zap binding depends on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 21:01:20 +00:00
Claude 8c5b71c3a5 Merge remote-tracking branch 'origin/main' into claude/chat-updates-concord-75jx1g
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt
2026-07-24 20:54:48 +00:00
Claude 3fb44dba60 feat: gallery view for My Blossom Files with per-file detail sheet
Replace the single-column list of large blob cards with an adaptive
thumbnail grid so many files no longer mean an endless scroll. Each tile
shows an image preview (or a type glyph) plus a corner badge summarizing
how many of the user's servers hold it (green check when on all, amber
cloud with a present/total count otherwise).

Tapping a tile opens a bottom sheet with the file's details: hash,
type/size, the per-server storage matrix ("Stored on"), the sync
(mirror-to-missing) button, and the copy/open/report/delete actions that
previously lived behind the card's overflow menu.

The ViewModel is unchanged — only the presentation layer moved from
list-of-cards to grid-plus-detail-sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
2026-07-24 20:38:43 +00:00
Claude 893a270c65 fix(edits): unlink deleted edits from their message; one collector per row
Audit of the three edit paths (feed 1010 / Buzz 40003 / Concord 3302) found:

1. Bug (feed regression): a deleted edit kept overlaying its message. Edits
   anchor on the target's Note.edits with no `replyTo` back-link, and
   removeNote didn't cover `edits`, so unlinkAndRemove never dropped them — the
   old cache-scan resolver dropped deleted edits for free, Note.edits did not.
   Fix: removeNote now also removeEdit()s, and unlinkAndRemove resolves the
   edit's `e`-tag target and unlinks it there (editedTargetIdOf covers all
   three kinds). New test: deleting an edit un-overlays and unlinks it.

2. Perf: every chat row ran two edits-flow collectors (observeConcordEdit +
   observeBuzzEdit). A message is only ever one kind, so they're merged into a
   single observeChatEdit that resolves latestConcordEdit() ?: latestBuzzEdit()
   — one collector per row, dispatched by the winning edit's event type.

3. Nits: latestBuzzEdit now tie-breaks by idHex (deterministic on same-second
   edits, matching Concord); dropped a redundant takeIf in latestConcordEdit.

The author check stays at read time on purpose: an edit can be consumed before
its target loads (author unknown), so an attach-time gate would wrongly drop
early-arriving legit edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 20:18:53 +00:00
Claude 6d111626ee fix(bolt12): don't let an unverified proof grief a verified zap total
The payment-hash dedup kept the LOWER amount and OR'd cryptoVerified across
entries. Because a payer proof publishes its proof_preimage, once a BOLT12 zap
is public anyone can replay its payment hash in a compressed (unverifiable)
proof with a 1-msat amount; the merge would keep that amount AND inherit the
verified flag, driving the counted total to ~zero and mislabeling a fabricated
amount as verified.

Per the NIP, dedup by invoice_payment_hash applies among *validated* proofs.
Only a crypto-verified proof has a signature-bound amount, so a verified entry
now always wins over an unverified duplicate; the lower-amount rule applies only
between entries of the same verification status. Adds a two-order regression
test for the replay-griefing case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 20:16:38 +00:00
Vitor PamplonaandGitHub 5d72a0415c Merge pull request #3696 from vitorpamplona/claude/geode-release-process-q1dp17
Add geode standalone Nostr relay with release pipeline
2026-07-24 16:08:14 -04:00
Claude f9ad60c423 refactor: move edit-overlay resolvers off LocalCache onto Note
findLatestModificationForNote (and the Buzz resolver) were pure Note.edits
filters with no LocalCache state — they only lived there for historical
reasons (back when resolving edits meant scanning the whole cache). Now that
every edit is a hard-referenced child of its message, resolution is a cheap
in-memory fold that belongs on the note.

New NoteEditOverlays.kt collects all three as Note extensions, so every edit
kind resolves the same way and none touches LocalCache:
- Note.textNoteModifications() (1010, author-only + NIP-40, version list)
- Note.latestBuzzEdit() (40003, author-only, newest by created_at)
- Note.latestConcordEdit() (3302, author-only, newest by CORD-02 send time)

Callers updated: observeEdits, observeNoteModifications, observeBuzzEdit,
observeConcordEdit, and the Buzz test. observeConcordEdit also drops its
early-return-before-produceState guards (a conditional-hook hazard) since the
resolver returns null for a non-Concord note anyway. LocalCache no longer
carries any edit-filtering logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 19:59:54 +00:00
Claude 70d38b205e fix(bolt12): hand offers off as bitcoin:?lno= (BIP21), not lightning:
A BOLT12 offer (lno1…) is not a lightning: payload — that scheme is for BOLT11
lnbc… invoices. Per BIP21/BIP321 an offer travels as the lno parameter of a
bitcoin URI (bitcoin:?lno=lno1…), with the on-chain address optional so a
Lightning-only offer stands alone. The bech32 offer value is URL-safe, so no
percent-encoding is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 19:53:38 +00:00
Claude 3a906e8b6b feat(bolt12): pay a recipient's BOLT12 offer via wallet intent
Adds a profile action that reads the recipient's kind:10058 offer list and,
for each published BOLT12 offer, hands it to an installed wallet via the
lightning: scheme (payViaBolt12Intent, sibling to the BOLT11 payViaIntent).
This is the first payment step — a plain wallet handoff, not a NIP-XX zap, so
it produces no Nostr receipt. NWC payment instructions are left for later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 19:45:31 +00:00
Claude 999bb14032 feat(bolt12): add editor to publish own kind:10058 BOLT12 offer list
Adds a Settings entry (mirroring the Payment Targets editor) that lets the
logged-in user add/remove reusable BOLT12 offers (lno1…) and publishes them
as a replaceable kind:10058 offer list. Offers are validated against
Bolt12Bech32 before being accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 19:43:07 +00:00
Claude 9fa1d769f9 refactor: drop cachedModificationEventsForNote, now a redundant alias
It existed to serve observeEdits a cheap synchronous value (an LRU read)
distinct from the expensive IO-only findLatestModificationForNote cache scan.
Since edits fold from Note.edits, findLatestModificationForNote is itself
cheap and thread-safe, and cachedModificationEventsForNote had become a plain
alias for it. observeEdits now calls findLatestModificationForNote directly —
the same function observeNoteModifications already uses — and the LocalCache +
AccountViewModel aliases are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 19:38:11 +00:00
Claude e8739f5e83 ci: run geode tests in a dedicated isolated job
The first cut appended :geode:test to the build-desktop matrix command.
That was wrong twice over: geode is JVM-only, so it ran 3× across the
ubuntu/macos/windows matrix, and — because org.gradle.parallel=true —
its default suite's CPU-heavy throughput benchmarks (a 1M-event mirror
sync, WireReqFloor, NegentropyServerReconcile) ran concurrently with the
timing-sensitive quartz relay-client tests, flaking
NostrClientReqBypassingRelayLimitsTest.denseSecondBeyondCapIsSteppedPastWithoutStalling.

Move :geode:test into its own test-geode job (needs: lint, ubuntu, JVM
21) so it runs once and its benchmark load can't starve another module's
timing assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
2026-07-24 19:37:09 +00:00
Vitor PamplonaandGitHub 99af7f58a3 Merge pull request #3695 from davotoula/fix/exoplayer-ownership-leak
Stop the decoder-budget counter from leaking and disabling the warm player cache
2026-07-24 15:36:07 -04:00
Claude c5d89ff7a0 feat(amethyst): cache + keep-live the BOLT12 offer list (kind 10058)
Downloads and keeps kind 10058 fresh the way the per-user relay/payment lists do,
so a recipient's BOLT12 offer is available for discovery before a zap.

- LocalCache consumes 10058 as an addressable note (consumeBaseReplaceable),
  keyed by Address(10058, pubkey, "").
- User gains a pinned bolt12OfferListNote + bolt12OfferList()/bolt12Offers()
  accessors — this is how a payer reads a recipient's offers.
- Bolt12OfferListState: the logged-in user's own offer list as live account state
  (a StateFlow<List<String>> of offers), persisted across restarts and published
  via saveOffers() — cloned from NipA3PaymentTargetsState. Wired into Account
  (bolt12OfferList + saveBolt12Offers), AccountSettings (backup + updater), and
  LocalPreferences (persist/restore).
- Subscriptions: kind 10058 added to FilterUserMetadataForKey (fetches OTHER
  users' offers — the key edit for zap recipients) and to the account's
  FilterAccountInfoAndListsFromKey (fetches your own at login).

Editor UI and the payment intent follow next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 19:28:43 +00:00
Claude 9075fb39b8 feat(quartz): add BOLT12 offer list (NIP-2421 kind 10058)
The NIP was updated to publish a recipient's BOLT12 offer(s) in a dedicated
replaceable event (kind 10058, `bolt12_offer`) instead of a kind:0 field — this
is what ties an offer to a Nostr identity (the author's signature) and gives
BOLT12 zaps the send-side addressability that lightning zaps get from lud16.

- Bolt12OfferListEvent (kind 10058): a BaseReplaceableEvent holding one or more
  `["offer","lno1..."]` tags (reusing OfferTag), with offers()/firstOffer()
  accessors and create/updateOffers factories (mirrors ChatMessageRelayListEvent).
- Registered in EventFactory + a KindNames display entry.
- Tests: offers round-trip + factory typing, malformed-offer-tag filtering, and
  updateOffers replacing the offer set while keeping other tags.

Discovery: a payer fetches the recipient's latest 10058 and picks an offer. The
app-side caching, subscription, editor, and payment intent follow in later commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-24 19:18:27 +00:00
Claude 0a30231196 feat(geode): add a release & distribution pipeline
geode was runnable only via ./gradlew :geode:run and was absent from CI.
Give it the same release process as the amy CLI (it's the same kind of
application-plugin JVM module), plus the pieces a long-running server
daemon needs that a one-shot CLI does not.

- Main.kt: add terminal --version/-V and --help/-h flags so a packaged
  binary has a fast, exit-0 command (Homebrew test block, package smoke
  checks, Docker healthcheck).
- build.gradle.kts: jlinkRuntime + geodeImage (portable flat app-image
  with a bundled JRE, plus config.example.toml + geode.service under
  share/) + jpackageDeb/jpackageRpm, mirroring cli/. No Compose to
  exclude — geode depends only on :quartz.
- Dockerfile + .dockerignore: multi-stage image (gradle installDist ->
  temurin JRE), the primary channel for relay operators.
- packaging/: systemd unit, macOS hardened-runtime entitlements, and a
  reference Homebrew formula.
- scripts/asset-name.sh: geode_asset_name/collect_geode_assets under the
  canonical geode-<version>-<family>-<arch>.<ext> scheme.
- create-release.yml: build-geode matrix (tarball + deb/rpm + no-JRE jvm
  bundle, with a serve+NIP-11 smoke test of the jlink image) and a
  docker-geode job pushing ghcr.io/<owner>/geode:<version> (+ :latest).
- bump-homebrew-geode-formula.yml: auto-sync the reference formula on
  stable releases.
- build.yml: run :geode:test in CI (it ran in no workflow before).
- README.md + plans/2026-07-24-geode-release.md: operator docs + design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
2026-07-24 17:56:50 +00:00
Claude 8dcee46cdf fix(buzz): only the author's own kind-40003 edit may rewrite a message
The Buzz edit overlay applied the newest kind-40003 by created_at regardless
of who signed it, so a 40003 signed by anyone — targeting someone else's
message — would rewrite that message in every reader's UI. The send side
already gates Edit to your own messages, but the apply side re-checked
nothing and effectively trusted the relay to reject cross-author edits.

Enforce author-only on apply, matching feed (1010) and Concord (3302) edits:
observeBuzzEdit now resolves through LocalCache.findLatestBuzzEditForNote,
which keeps only edits whose author is the original message's author (newest
by created_at — Buzz's own last-write-wins rule otherwise). There is no Buzz
feature that edits another user's message; moderation is delete/hide.

Added a test: a verified forged edit by a different author lands in the store
but never overrides the message, while the real author's later edit does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 17:54:23 +00:00
Claude 98d8f88c0a refactor: unify all message edits onto Note.edits
All three edit kinds now anchor on the message they edit via the same
Note.edits collection, instead of each maintaining its own store:

- Feed edits (kind 1010): consume(TextNoteModificationEvent) calls
  editedNote.addEdit(note); findLatestModificationForNote folds note.edits
  (author-only, NIP-40 expiry) instead of scanning the whole cache. Drops
  the O(all-notes) scan and the 20-entry modificationCache LRU;
  cachedModificationEventsForNote is now synchronous (no Loading state).
- Buzz edits (kind 40003): consume(StreamMessageEditEvent) calls
  target.addEdit(note); observeBuzzEdit reads note.edits (newest by
  created_at, no author gate — Buzz's own rule). Removes the channel-keyed
  BuzzWorkspaceState edit store, its editUpdates/editFor/effectiveContentFor/
  addEdit and the pruneEdits reaping (edits now prune with their message).
- Concord edits (kind 3302): already on note.edits.

Each reader keeps its own semantics by filtering note.edits on its event
type; the shared field only unifies storage + lifecycle, so an edit lives
exactly as long as the message it edits. Buzz edit tests rewritten against
note.edits (6/6 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 17:39:03 +00:00
davotoula 874209ab39 Code review:
- don't double-return the player if register() throws
- guarantee pool teardown even if a session retire throws
- simplify pool ownership code, one funnel and paired counters
2026-07-24 19:18:34 +02:00
davotoula 0bd08bf013 Manual testing fixes:
- fix(playback): remove pool from livePools after its own teardown
- fix(playback): floor decoder decrements and report teardown drift
- fix(playback): own the player across the acquire-to-register window
- fix(playback): give every pooled player exactly one owner
2026-07-24 19:18:34 +02:00
davotoula bb41a867a4 feat(playback): add SessionRegistry owning session reachability
test(playback): pin SessionRegistry's identity-vs-equality drop guard
2026-07-24 19:18:34 +02:00
Vitor PamplonaandGitHub 94210d202d Merge pull request #3694 from vitorpamplona/claude/remove-module-warnings-s7v2q0
Refactor persistent collections API and fix deprecation warnings
2026-07-24 13:03:07 -04:00
Claude a7748dfda5 fix: clear compiler warnings across all modules
Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
2026-07-24 16:36:17 +00:00
Vitor PamplonaandGitHub 32d0105744 Merge pull request #3693 from vitorpamplona/claude/changelog-0-13-0-sht28q
docs: update v1.13.00 changelog with Buzz, Notifications, and more
2026-07-24 12:35:22 -04:00
Vitor PamplonaandGitHub 62e7d9bb7a Merge pull request #3692 from vitorpamplona/claude/geode-quartz-eventstore-selection-zht375
Add pluggable event store backend selection via StoreFactory
2026-07-24 12:30:49 -04:00
Claude 7d527bdce3 feat(geode): let operators pick any quartz IEventStore backend
Geode hard-wired the SQLite EventStore. Add a `[database].backend`
selector (and `--store` CLI flag) so an operator can choose the store
implementation:

  - "sqlite" (default): the SQLite EventStore, unchanged.
  - "fs": quartz's filesystem FsEventStore, rooted at [database].file.
  - any other value: a fully-qualified class name of a custom
    IEventStore on the classpath, instantiated reflectively via one of
    `(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`,
    or `()` — the "plug in anything" escape hatch.

Store construction moves into a new StoreFactory (mirrors cli's
StoreFactory) shared by the serve path and the import/export verbs, so
both open the same store from the same config. The SQLite-only
`PRAGMA optimize` maintenance loop now runs only when the resolved
store is the SQLite one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GG3TBvLUv5uB5js1naG5sc
2026-07-24 16:15:21 +00:00
Claude fa2179ac19 docs(changelog): fold post-2026-07-20 work into v1.13.0 notes
Adds Buzz agent workspaces, the push-notification redesign, notifications
tab paging, desktop moderation & safety plus NIP-88 polls, DM report
warnings, Concord cold-boot/rank-gating fixes, SQLite search/relay
performance, amy git NIP-34 parity and amy buzz, the Buzz quartz protocol
surface, geode index work, and JVM 17 / CI / bidi build items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018o1pqCDfqqSu2UHRPuqTK1
2026-07-24 15:50:08 +00:00
Claude 2213031f9f fix(concord): anchor edits to their message so they survive cache eviction
LocalCache.notes is a soft cache, and a Concord rumor is decrypted exactly
once per session (the community session dedups re-delivered wraps), so a
kind-3302 edit left orphaned there could be GC'd on navigation and never
re-downloaded — the message would silently revert to its pre-edit text.
Resetting the channel EOSE doesn't help: the re-delivered wrap is swallowed
by the session's isNew dedup, so its rumor never re-emits.

Fix it the way reactions/replies already survive: attach the edit to the
message it edits. consume(ConcordChatEditEvent) now calls target.addEdit(note),
so the edit is held for exactly as long as its channel-retained message (and
released with it via clearChildLinks). observeConcordEdit reads note.edits
directly — author-matching, latest by CORD-02 §4 send time — instead of
scanning/observing the soft cache.

- Note: new hard-held `edits` collection + addEdit/removeEdit, wired into
  clearChildLinks like the other child-event links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 15:48:29 +00:00
Vitor PamplonaandGitHub c6fd7095d4 Merge pull request #3690 from davotoula/fix/localcache-duplicate-streammessage-branch
Remove unreachable duplicate StreamMessageV2Event branch in computeReplyTo
2026-07-24 11:37:50 -04:00
Vitor PamplonaandGitHub c5369da6db Merge pull request #3691 from vitorpamplona/claude/calendar-rsvp-buttons-layout-408xgu
Improve RSVP button layout and text handling
2026-07-24 10:09:25 -04:00
David KasparandGitHub 8a61e8c5ff Merge pull request #3689 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-24 16:02:35 +02:00
vitorpamplonaandgithub-actions[bot] d15c0d1eab chore: sync Crowdin translations and seed translator npub placeholders 2026-07-24 12:54:13 +00:00
Vitor PamplonaandGitHub 7c1300b735 Merge pull request #3687 from nrobi144/feat/desktop-moderation-safety
feat(desktop): Moderation & Safety — mute/block enforcement, NIP-56 report, NIP-36 content warning
2026-07-24 08:51:16 -04:00
davotoulaandClaude Opus 4.8 34ceaafa78 fix(buzz): remove unreachable duplicate StreamMessageV2Event branch in computeReplyTo
LocalCache.computeReplyTo had two `is StreamMessageV2Event ->` branches in the
same `when`. The `when` matches the first, so the second (the older
buzzThreadRoot+buzzThreadReply variant) was dead code — the condition Sonar
flags as duplicating the earlier one. The surviving first branch is the newer,
deliberate Concord-style threading behavior that links a 40002 thread reply into
its parent's replies via buzzThreadReply. Also drops the now-unused
buzzThreadRoot import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoYFvsXvPigRWebuoGiRoG
2026-07-24 14:38:02 +02:00
davotoula f02490a078 Merge remote-tracking branch 'upstream/main' into main-upstream 2026-07-24 14:24:49 +02:00
David KasparandGitHub 6fb78c33b6 Merge pull request #3688 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-24 14:10:22 +02:00
davotoula 5aab994649 update cs,sv,de,pt 2026-07-24 13:07:57 +02:00
davotoulaandgithub-actions[bot] 611f030207 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-24 10:52:56 +00:00
David KasparandGitHub bf8c2413a9 Merge pull request #3681 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-24 12:50:08 +02:00
nrobi144andClaude Opus 4.8 1a0b9eb2d9 docs(desktop): moderation test results (PASS) with published-report logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:17:28 +03:00
nrobi144andClaude Opus 4.8 55d11acd61 feat(desktop): unify note ⋮ + right-click menus and add snackbar feedback
- rememberNoteMenuActions is now the single source for the note menu; both the
  ⋮ overflow (ShareMenu) and the feed right-click ContextMenuArea render the same
  items (copy ×5 / broadcast / mute / report).
- LocalSnackbarHost exposes the app SnackbarHostState so moderation actions show a
  confirmation: 'Muted user', 'Report sent', 'Reported & muted', 'Broadcast to
  relays', and failure toasts. Wired for the note menu + profile ⋮ actions.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:51:51 +03:00
vitorpamplonaandgithub-actions[bot] 7cc4c63996 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-24 04:32:24 +00:00
Vitor PamplonaandGitHub 313e04b7df Merge pull request #3683 from vitorpamplona/claude/push-notification-design-fzyutz
Redesign push notifications with per-kind styles and channels
2026-07-24 00:29:22 -04:00
Claude 02c59b332e fix: keep calendar RSVP buttons on a single line
The Going / Maybe / Can't go buttons in the calendar feed card wrapped
to two lines because the default button content padding (24dp horizontal)
left no room for "Can't go" in an equal-weight third of the row.

Tighten the content padding, shrink the row spacing, and constrain each
label to a single line so the three buttons fit horizontally.
2026-07-24 04:04:07 +00:00
Claude d91c15d97b fix: attribute reply-notification parent to its real author, not always "Me"
The parent message shown above a reply in the MessagingStyle notification was
unconditionally labeled "Me" with the account's avatar. That's wrong whenever
the account is notified as the *root* author of a thread but the direct parent
belongs to someone else — e.g. Vitor starts a thread, fiatjaf replies, a third
person replies to fiatjaf: fiatjaf's note was rendered as Vitor. This surfaced
through the NIP-22 comment and public-chat reply paths, which notify on
root-authorship, not just direct-parent authorship.

ReplyNotification now receives the parent Note (not a bare content string) and
resolves its actual author: attributed to the MessagingStyle `me` Person only
when the parent truly is the account's, otherwise shown as the real author with
their name + avatar, observed for enrichment like the replier. postConversation
reuses the `me` Person for a self-authored parent so it still renders as you.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-24 03:45:49 +00:00
Claude 6df7398d02 feat: render inline image links as the notification big picture
When a mention/article body contains an inline `http(s)` image link, show the
image as the notification's big picture (BigPictureStyle) instead of leaving the
raw URL in the text. NotificationContent.renderNoteText() now pulls the first
image URL out of the content — scanning all lines, since images usually sit on
their own line — using the cheap RichTextParser.isImageUrl extension check, and
strips that link from the excerpt. Videos are left in the text (Coil can't load
them as a still). The Mention and Article renderers pass the extracted URL as
bigPictureUrl; Reply stays MessagingStyle (a big picture doesn't fit a chat
bubble) and keeps its text as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-24 02:59:19 +00:00
Claude c5b8062a0c feat: resolve inline @npub mentions to display names in notifications
Notification bodies previously showed raw `nostr:npub1…` / `nostr:nprofile1…`
tokens for anyone cited in the text — the name never filled in even after the
cited user's kind:0 loaded, because the excerpt was a static substring taken
once and never re-resolved against the metadata cache.

Add NotificationContent.resolveMentions(), which rewrites each cited npub/
nprofile token to `@<best display name>` and returns the cited Users so the
renderer can add them to its enrichment window. Event references (nevent/note/
naddr) are left verbatim — they have no name to show. Mention, Reply, Media and
Article renderers now recompute the body inside the observable build closure and
observe the cited users, so the text flips from `@npub1abc…` to `@RealName` in
place, matching the author-name enrichment the title already had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-24 02:53:39 +00:00
Claude 1881bc919a refactor(concord): observe edits via LocalCache.observeEvents
Watch kind-3302 edits reactively through LocalCache.observeEvents, narrowed on
the edit's `e` tag, instead of a whole-cache scan wired to the target note's
edits flow. The index-backed observer seeds from any edit already cached and
wakes on each new one, matching how the app observes reactions, Nest presence,
and git PR updates.

- observeConcordEdit now collects observeEvents<ConcordChatEditEvent>({kinds,
  "#e"}); it filters to the original author and takes the latest by send time.
- consume(ConcordChatEditEvent) drops the manual edits-flow invalidation and
  just lands the event + wakes observers (refreshNewNoteObservers).
- Removed the now-unused findLatestConcordEditForNote scan and concordEditCache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 02:10:50 +00:00
Claude 92915c9b17 fix(concord): use the dedicated kind-3302 edit, matching Armada's wire format
Verified against the Concord v2 reference client (Soapbox Armada,
src/concord-v2/lib/kinds.ts): a chat message edit is a dedicated KIND_EDIT =
3302 rumor, NOT a kind-1010 modification. It names the target with a single
`e` tag (no `k` — Armada adds `k` only to deletes), carries the replacement
text, and rides the channel/epoch binding. The fold applies only edits
authored by the original message's author (latest by CORD-02 §4 send time
`created_at*1000 + ms`), non-destructively.

The prior commit used kind-1010 TextNoteModificationEvent, which would not
interop with Armada. Corrected:

- New ConcordChatEditEvent (kind 3302) in quartz, registered in EventFactory;
  ChannelChat.edit now builds it. orderingMs() honors the `ms` remainder tag.
- LocalCache.consume(ConcordChatEditEvent) wires the edit to its target note
  and invalidates the edits flow; findLatestConcordEditForNote returns the
  author-matching kind-3302 edits ordered by send time (latest wins).
- observeConcordEdit reads that finder instead of the kind-1010 machinery.

Send/compose/action-sheet plumbing is unchanged (it routes through
ChannelChat.edit). Note: Amethyst does not yet emit the `ms` remainder tag on
Concord rumors (a pre-existing, message-wide gap), so its own edits order at
one-second granularity; received Armada edits are ordered at full precision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-24 00:58:39 +00:00
Claude a5925c16c2 feat(concord): let authors edit their own channel messages
Brings feed-post-style edits to Concord chat. A Concord message rumor is a
standard kind-9 event, so an edit reuses Amethyst's native kind-1010
TextNoteModificationEvent: a channel/epoch-bound rumor that e-tags the target
and carries the replacement text, wrapped and published on the same channel
plane as any other Chat Plane rumor. Receivers overlay the newest edit through
the existing shared machinery (LocalCache.findLatestModificationForNote), which
only applies edits authored by the original message's author, so a member can't
rewrite someone else's message. Clients that don't understand kind-1010 keep
showing the original text, so it degrades gracefully.

- ChannelChat.edit + ConcordActions.buildChannelEdit build/wrap the edit rumor.
- Account.editConcordChannelMessage gates to my own kind-9 messages and
  publishes the wrap (local echo + relays), mirroring reactToConcordMessage so
  the edit never leaks the private rumor id onto public relays.
- The chat bubble overlays the newest edit (RenderConcordEditedNote) with an
  "(edited)" marker, matching the Buzz kind-40003 edit presentation.
- The long-press action sheet offers Edit on my own Concord messages; the
  composer enters edit mode with an editing banner and publishes the edit on
  send. The former onWantsToEditBuzz callback is generalized to
  onWantsToEditChatMessage, shared by the Buzz and Concord surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
2026-07-23 22:15:43 +00:00
Claude 362844ee73 Merge remote-tracking branch 'origin/main' into claude/nip-2421-pr-review-6znvdd
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
2026-07-23 21:37:47 +00:00
Claude c5941942be Merge remote-tracking branch 'origin/main' into claude/push-notification-design-fzyutz 2026-07-23 21:36:31 +00:00
Vitor PamplonaandGitHub 5800571b77 Merge pull request #3686 from vitorpamplona/fix/concord-list-and-channel-previews
fix(concord): recover communities that won't load + warm channel previews
2026-07-23 17:31:29 -04:00
Vitor PamplonaandClaude Opus 4.8 69ef23d7ed feat(concord): warm channel previews so the list fills without opening each channel
Concord fetched a channel's messages only when its screen was open (the history
pager mounts on the channel screen), so an un-opened channel showed "No messages
yet" in the community list and never appeared in the Messages inbox — unlike
NIP-28 / NIP-29, which preload a last-message per room. Add a one-shot warm
drain, `Account.warmConcordChannelPreviews`, triggered on community-screen open
and app-wide per subscribed community from the account preload (both debounced
so the cold-boot fold burst warms once).

Per channel (`ConcordSubscriptionPlanner.channelPreviewFilters`):
- never read -> the newest `previewLimit` (10) wraps: a preview plus a rough
  sense of how busy the channel is, without pulling the whole backlog.
- read -> everything `since lastRead - 1` (capped at `catchUpLimit`): the unread
  badge is accurate and the missed messages are cached for on-open; the `-1`
  re-includes the last-read message (its created_at == lastRead) so a caught-up
  channel still shows a preview, and unread stays exact (the count is strict `>`).

Filters group by relay into one REQ per relay (one filter per channel); the
wraps ingest through the normal cache path, and the always-on plane subscription
keeps them fresh afterward. Full history still pages in on open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:26:09 -04:00
Vitor PamplonaandClaude Opus 4.8 31acb5037b fix(concord): decode the community list even when an entry has a keyless channel
A public channel carries no delivered key, so a writer lists it under an
entry's `channels` with only {id, epoch, name}. `WireChannel.key` was a
required field, so kotlinx.serialization threw MissingFieldException — and
`decodeDocument`'s catch-all turned that one bad channel into an empty list,
silently dropping EVERY joined community from the kind-13302 list (communities
"won't load" at all).

- Default `WireChannel.key = ""` so a keyless (public) channel no longer throws.
- Decode entries one at a time and keep any we still can't parse verbatim in
  `ConcordListResidue.unparsedEntries`, re-emitted on write — so one malformed
  entry can never wipe the whole list, and a read-modify-write never deletes a
  membership this version can't model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:25:52 -04:00
Claude 5001d4a27a test(notifications): cover Buzz reactions in the e2e harness + document Buzz DM
- run.sh: add a Buzz-style bare reaction check (kind-7 with an `e` tag but no `p`
  tag) asserting it still lands on the Reactions channel, and a note that Buzz DM
  isn't auto-triggered.
- README: coverage row for the bare reaction, plus a "Buzz DM (manual)" section
  with the channel-setup steps (kind-39000 `t=dm` + participant, then post
  kind-9/40002) since participant-routing needs cached channel metadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 21:25:29 +00:00
Claude 0b6a70ad26 fix(bolt12): audit fixes — offer binding, verified-only counting, lower-amount dedup, codec hardening
From an adversarial audit of the BOLT12-zap feature.

Security / correctness:
- Offer↔invoice binding: `cryptoVerified=true` was asserted even when the offer had
  no `offer_issuer_id` or used blinded paths — cases where the invoice's node key is
  payer-chosen and can't be tied to the offer. An attacker could self-sign a "verified"
  proof having paid nothing. Now cryptoVerified requires the invoice to be provably the
  offer's (issuer_id present, no paths, invoice_node_id == issuer); unbindable proofs are
  accepted but flagged unverified, not verified. Definite contradictions still hard-reject.
- Counting: `updateZapTotal` now counts ONLY crypto-verified BOLT12 zaps. An unverified
  (compressed / unbindable) proof carries a self-chosen preimage+amount with no settled-
  payment guarantee, so counting it let anyone inflate a note's total for free. Unverified
  entries stay stored + shown (dimmed), never summed.
- Dedup: `innerAddBolt12Zap` now honors the NIP's "count the LOWER amount for the same
  payment hash" rule (was order-dependent last-writer-wins, inflatable by re-publishing a
  bigger amount tag). Keeps the stronger verification flag.
- Precision: divide millisats in BigDecimal, so fractional sats survive and match the
  millisat-native lightning column (was integer `/1000`, flooring sub-sat zaps to 0).

Codec hardening (quartz):
- TLV length now range-checked (was a signed compare that let a high-bit BigSize length
  slip through and get truncated by toInt()).
- BigSize enforces minimal encoding (also rejects >=2^63 values that read back negative).
- bech32 alphabet membership is O(1) via a lookup table (was O(32n) indexOf per char).

UI:
- ReusableZapButton's "you zapped" gate now includes bolt12Zaps (and nutzaps/onchain),
  so a BOLT12-only zap correctly shows the zapped state.
- The reactions gallery renders the blank/unknown author for anonymous zaps, matching the
  standalone card (was showing the throwaway ephemeral key's avatar).

Tests: validator issuer-less-offer downgrade; TLV non-minimal-BigSize + oversized-length
rejection; model lower-amount dedup (both orderings), verified-only counting, and
fractional-sat survival. NoteBolt12ZapTest 6→8, Bolt12ZapValidatorTest 11→12, TlvTest 6→8.

The audit also surfaced a NIP-level gap that is NOT fixable in code and is captured in the
plan doc: there is no offer↔recipient-identity binding, so even a crypto-verified proof
only proves payment to the *embedded* offer, not to the p-tagged recipient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-23 21:17:01 +00:00
Claude 77dd1a5585 feat(notifications): Buzz DM push notifications + repost mute parity
Buzz DM messages (NIP-29 relay-group StreamMessageV2Event kind 40002 / ChatEvent
kind 9 in a `t=dm` channel) carry no `p` tag — participation is the relevance
signal — so main added them to the in-app feed only. This wires them into push:

- BuzzDmNotification renderer: MessagingStyle on the Private Messages channel,
  channel name as the conversation title, "sender: text" body, sender avatar,
  deep-links to the relay-group chatroom via the channel's kind-39000 naddr
  (reusing the existing naddr → Route.RelayGroup path). Honors the
  "show messages in notifications" toggle and enriches the sender observably.
- Dispatcher: adds kinds 40002/9 to NOTIFICATION_KINDS and gates them
  EXCLUSIVELY on Buzz-DM membership (buzzDmChannelForMe) so ordinary kind-9
  chats (Concord / NIP-C7) don't leak into the tray.
- NotificationRoutes.relayGroupUri for the channel deep-link.

Also closes a mute-parity gap the audit surfaced: the push muted-thread check
covered Reaction/LnZap but not Repost/GenericRepost (the feed mutes all four) —
so a bare Buzz repost of your note on a muted thread now stays muted in push too.

Buzz reactions/reposts (bare no-`p`-tag likes) already route to the existing
Reaction/Repost renderers, which are correct for Buzz (plain kind-7/6/16, same
target resolution and emoji semantics) — verified, no change needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 21:02:54 +00:00
Vitor PamplonaandGitHub acebf96eb9 Merge pull request #3684 from vitorpamplona/claude/pow-miner-cancel-behavior-18ckmk
Add "send without PoW" option for template posts during mining
2026-07-23 16:46:45 -04:00
Claude 3e19b76343 perf(bolt12): fail-fast validation, skip redundant verify, precompute merkle tags
Speed:
- Bolt12ZapValidator reorders checks cheap-to-expensive: all structural, cross-event,
  and payer-proof binding checks run first; the schnorr signature + proof crypto
  verifications run only once an event has passed them. A malformed or mismatched
  event now rejects with zero schnorr ops. Cannot change accept/reject, only which
  reason a doubly-invalid event reports.
- validate() gains verifyEventSignature (default true); LocalCache.consume passes
  false since the relay pipeline already verified the outer event — removing a
  redundant schnorr on every ingested zap (3 verifies instead of 4).
- Bolt12Merkle precomputes SHA256("LnLeaf")/SHA256("LnBranch") once and hashes the
  per-call "LnNonce"||first-tlv tag once per rootHash instead of once per record.

Tests:
- New validator rejections: preimage-mismatch, invalid-invoice-signature,
  payer-tag-mismatch, proof-does-not-match-offer, plus the verifyEventSignature
  skip-flag both ways (fixture gains corruptPaymentHash / breakInvoiceSignature).
- New commons NoteBolt12ZapTest: millisat→sat total, dedup by payment_hash,
  verified-not-downgraded-by-unverified, remove-by-source, clearChildLinks, and
  combined totals.

Docs: quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md captures the two
upstream-gated follow-ups (vector-driven interop test + compressed-proof merkle
reconstruction) for when lightning/bolts#1346 merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-23 20:43:02 +00:00
Claude c55f46cb2b feat(notifications): push-notify Buzz-style bare reactions/reposts to your note
A Buzz like is a bare ["e", <id>] reaction with no p tag, so the push gate's
(isTaggedUser || publicChatReply) pre-clause rejected it even though
tagsAnEventByUser already recognizes a reaction/repost whose reacted note is
mine. Relax the pre-clause for reaction/repost kinds; tagsAnEventByUser keeps it
scoped to reactions on my own note. Reuses the existing Reaction/Repost
renderers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 20:33:49 +00:00
Claude 33fb3a54b2 feat: account for and display BOLT12 zaps everywhere lightning zaps are
Wires the receiving side of NIP-XX BOLT12 zaps (kind 9736) into every place a
NIP-57 lightning zap is counted or shown. Sending is intentionally left for
later. Modeled on the lightning-zap scheme (synchronous, the proof carries the
amount, counted the moment it validates) rather than the onchain scheme (async
chain backend, PENDING/CONFIRMED, CONFIRMED-only) — BOLT12 proof verification is
a self-contained synchronous check, so no resolver/backend is needed.

Model (commons):
- Bolt12ZapEntry + Note.bolt12Zaps map keyed by the proof's invoice_payment_hash
  (the spec dedup key); addBolt12Zap/removeBolt12ZapBySource; folded into
  updateZapTotal (millisats → sats) alongside lightning/onchain/nutzap amounts;
  wired into clearChildLinks, moveAllReferencesTo, removeNote,
  hasZapsBoostsOrReactions, hasZapped, and the isZappedBy family.

Ingestion (LocalCache):
- consume(Bolt12ZapEvent): validate synchronously via Bolt12ZapValidator, then
  addBolt12Zap on the resolved targets (e / a / profile); computeReplyTo and
  live-activity channel routing branches; dispatch case.

Subscriptions: added kind 9736 to every filter carrying LnZapEvent.KIND
(notifications, replies/reactions to notes & addresses, profile received-zaps,
live-activity goal + messages, nest room + collectors, notification dispatcher,
shared NotificationKinds, app-functions).

Aggregation / notifications: UserProfileZapsViewModel (mapper), NotificationSummaryState
(both passes), NotificationFeedFilter (kinds, zap-receipt detection, payer author
resolution, muted-thread + own-event gates), NotificationKinds own-event exception,
ThreadAssembler.anchorsItsOwnThread, and the commons live-activity aggregators
(RoomZapsState, LiveStreamTopZappers, NestViewModel).

UI: RenderBolt12Zap standalone card (styled like the lightning card, labeled
BOLT12) wired into NoteCompose + ThreadFeedView; Bolt12ZapGallery in the
reactions row (payer avatars + amounts, unverified/compressed proofs dimmed);
reaction-row counter gate; KindNames / KindDisplayName entries.

Note: validated BOLT12 zaps are counted immediately; a compressed proof whose
signatures aren't yet verifiable (pending lightning/bolts#1346 merkle
reconstruction) is stored with cryptoVerified=false and dimmed in the gallery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-23 20:08:46 +00:00
Claude 2db2f8d3ca Merge remote-tracking branch 'origin/main' into claude/push-notification-design-fzyutz 2026-07-23 20:00:26 +00:00
Claude 7a3fc48f07 feat: offer "send without PoW" when abandoning a mining job
Cancelling a proof-of-work mining job used to silently discard the post
(the sign+broadcast continuation only ran on a successfully mined
template). Users had no way to publish the note un-mined once they'd
started waiting.

Now abandoning a template post asks what to do instead of discarding:

- The × on the mining banner opens a dialog with "Send without PoW",
  "Discard post", or tap-away to keep mining. Only shown for jobs that
  carry a plain un-mined fallback (template posts); opaque work jobs
  (reactions, reposts, anonymous posts, gift wraps) keep the direct
  cancel since they have no template to fall back to.
- The mining foreground-service notification gains a "Send now" action
  that publishes every eligible queued post without proof of work.

Implementation: the queue keeps the un-mined publish continuation
alongside the miner. sendWithoutPow() sets a flag the worker picks up on
its next isActive poll; the miner aborts and the plain template is
published through the same sign+broadcast path the mined template would
have used, off the worker pool. PoWJobState exposes canSendWithoutPow so
the UI knows which jobs support it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kLVFV7ps4HfXDDJPNqi82
2026-07-23 20:00:16 +00:00
Vitor PamplonaandGitHub a6e6903f6d Merge pull request #3682 from vitorpamplona/claude/buzz-repo-analysis-7k54ga
Add Buzz protocol support with workspace, DM, and agent features
2026-07-23 15:45:09 -04:00
Claude a657a9698d fix(buzz): use assertTrue instead of assert in ForumCommentEventTest
Kotlin/Native marks the stdlib `assert()` with `@ExperimentalNativeApi`, so
the iOS test target (`compileTestKotlinIosSimulatorArm64`) failed to compile
without an opt-in — while JVM/Android were fine. Swap it for `assertTrue`
from `kotlin.test`, which needs no opt-in on any target and matches the
assertions already used in this file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 19:19:27 +00:00
Claude 2ec1744c92 feat(quartz): add BOLT12 zaps (NIP-2421) protocol layer
Implements the quartz-side of the proposed "BOLT12 Zaps" NIP
(nostr-protocol/nips#2421): public, self-verifying zap events that prove a
BOLT12 payment without an LNURL server or recipient-operated receipt publisher.

Events (nip-88 style templates/tags/builders):
- Bolt12ZapEvent (kind 9736) and Bolt12ZapIntentEvent (kind 9737)
- shared tags: amount (msats), offer, proof, P (payer), zap_id, description
- registered both kinds in EventFactory

BOLT12 decoding (new, no existing KMP library):
- Bolt12Bech32: canonicalization (+ continuation / whitespace) + no-checksum,
  no-length-limit bech32 for lno1 offers and lnp1 payer proofs
- Tlv: BigSize codec, TLV stream reader/writer, tu64 helpers
- Bolt12Offer / Bolt12PayerProof parsers (proof TLV types per lightning/bolts#1346)
- Bolt12Merkle: BOLT12 tagged-hash + signature merkle root + signature digest

Validation:
- Bolt12ZapValidator runs the NIP's steps (structure, embedded-intent match,
  payer-proof binding: invreq_payer_note == nostr:nipXX:<intent-id>,
  invoice_amount == amount) and returns a typed result with the payment-hash
  dedup key
- Bolt12ProofVerifier checks preimage->payment_hash and the invoice/proof
  BIP-340 signatures for fully-disclosed proofs; compressed proofs are reported
  as unverified pending the (still-draft) lightning/bolts#1346 test vectors
- Bolt12ZapBuilder assembles+signs the intent and the final zap

Tests: 24 commonTest cases covering bech32/TLV/merkle round-trips, event tag
structure + factory typing, and validator accept/reject paths (self-signed
BOLT12 fixtures exercise the full merkle + schnorr path).

Note: this is the receive/verify + assembly layer only. Origination is blocked
on the upstream BOLT12 payer-proof spec merging and a wallet/NWC rail exposing
lnp proofs; no Amethyst payment rail returns one today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-23 19:15:30 +00:00
Claude 210aa86c2f Merge remote-tracking branch 'origin/claude/buzz-repo-analysis-7k54ga' into claude/buzz-repo-analysis-7k54ga 2026-07-23 18:43:47 +00:00
Claude 9ab1c650a9 feat(buzz): move workspace add-people & invite-link into top-bar overflow menu
The two workspace-owner actions (Add people to this workspace, Create invite
link) sat as an inline button row above the channel list. Move them into a
3-dot overflow (MoreVert) menu in the top-right of the workspace top bar,
matching the Concord community pattern. Fold the invite-mint flow (spinner,
result Copy/Share dialog, error dialog) into the new BuzzWorkspaceOverflowMenu
and drop the now-obsolete BuzzInviteMintButton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 18:43:24 +00:00
Vitor PamplonaandGitHub 66108d8aa1 Merge pull request #3680 from davotoula/feat/644-warn-dms-from-reported-senders
Warn users when a DM comes from an account that people they follow have reported
2026-07-23 14:41:30 -04:00
Vitor PamplonaandClaude Opus 4.8 3a9d9a7b5d fix(buzz): show bare Buzz reactions (e-tag only) in Notifications
A Buzz like is a bare kind-7 `["e", <msg-id>]` — no `p` tag (unlike NIP-25,
which p-tags the reacted author) and no `h` tag. The notifications filter's
p-tag gate and follow filter therefore both dropped it, so a like on my Buzz
message never surfaced even in Global mode, despite the reaction being loaded
(it rendered as a chip in the open conversation).

Recognize a reaction/repost that carries NO `p` tag whose reacted target — the
last `e` tag, already loaded — is my own note, and let it bypass the follow
filter and satisfy the p-tag gate (like a Concord reaction). Scoped to the
no-`p`-tag case so well-formed NIP-25 reactions keep their normal routing, and
to an already-loaded target so it can't accept blindly. The per-kind gate
already resolves the same target author, so it needs no change.

Verified on emulator: a 👍 + ❤️ on my Buzz DM message now appears on the
Curated notifications tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:35:41 -04:00
Vitor PamplonaandClaude Opus 4.8 8982af5752 feat(buzz): notify on reactions/zaps to my Buzz DM messages
The group-notification query (reactions/zaps/reposts on my messages inside a
NIP-29 group, kind-7 etc. scoped #p=me + #h on the host relay) sourced its #h
set from the published kind-10009 group list — which deliberately excludes Buzz
DM channels (membership is server-side, tracked in BuzzDmChannels). So a
reaction on my DM message was only ever loaded as a chip inside the open
conversation, never surfacing on the Notifications tab.

Include DM channels (from BuzzDmChannels, minus hidden ones) alongside the
joined groups in both the live-tail (AccountNotificationsEoseFromInboxRelays-
Manager) and backward-history (AccountNotificationsHistoryEoseManager) queries,
reusing the same filterGroupNotificationsToPubkey builder, and re-subscribe when
a DM is discovered/hidden. Regular-channel reactions already worked via the
joined-group path; this closes the DM gap the same way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:35:41 -04:00
Claude 8600484d0b Merge remote-tracking branch 'origin/main' into claude/buzz-repo-analysis-7k54ga 2026-07-23 18:31:44 +00:00
davotoula 8b7dac5c0b Styling updates 2026-07-23 19:24:43 +02:00
davotoula 4405e20eb7 Code review:
- docs(dm): note the profile Reports tab also reads reportsNamingUser
- refactor(dm): push report-tag typing to quartz and simplify the warning stack
- fix(dm): narrow report indexing and address final review findings
- perf(dm): resolve a 1:1 chat row's counterpart once per row
2026-07-23 19:23:43 +02:00
davotoula 3e1d537eac feat(dm): flag reported counterparts on the chat list row
fix(dm): dedupe reporter avatars and align warning card styling
feat(dm): warn in the room when the counterpart is reported by a follow
feat(dm): expose a report-warning flow per user
refactor(reports): extract reusable reportTypeLabel composable
feat(reports): index author-named reports even when they target an event
feat(reports): add pure DM report-warning classifier
feat(reports): add additive reportsNamingUser index to UserReportCache
2026-07-23 19:23:43 +02:00
davotoula b2fa1d3b92 upgrade agp 2026-07-23 18:53:54 +02:00
David KasparandGitHub 5c4a7e8620 Merge pull request #3679 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-23 17:35:07 +01:00
Claude 647997f895 refactor(buzz): audit pass — fix draft-save invite bug, races, GC churn
Batch of correctness, performance, and code-quality fixes across the Buzz
workspace feature surfaced by a full audit of the branch's modified files:

- ChannelNewMessageViewModel: agent auto-invite ran inside createTemplate(),
  which sendDraftSync() calls per keystroke — so @mentioning a member
  published real kind-9000 invites while still typing. Move the invite to the
  send path (sendPostSync) and stage the pending mentions instead.
- AgentConsoleViewModel: fix a decryptCache read/reload race by guarding
  reloadFromCache (not refresh) under the mutex; bound observerSeen growth.
- BuzzNewDmViewModel: broaden start()'s catch so signer/IO/timeout failures
  surface as an error instead of leaving Start stuck on "Sending"; rethrow
  CancellationException.
- BuzzPresenceState / BuzzAgentActivityState: replace per-record full-map
  copies with persistent maps (structural sharing) to cut GC churn; the
  StateFlow now holds the map directly.
- RelayGroupChannelListScreen / RelayGroupMembersScreen / RelayGroupTopBar /
  RenderBuzzNotes: correct remember/LaunchedEffect keys so state rebinds when
  the relay or channel changes.
- RelayGroupDiscoveryFeedFilter: hoist joinedGroupIds() out of the per-item
  matches() loop.
- RelayGroupChannel / BuzzWorkspaceStates: @Volatile the fields read off relay
  dispatcher threads.
- BuzzInviteMinter: build the request URL through HttpUrl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 16:32:26 +00:00
Vitor PamplonaandClaude Opus 4.8 995d0e8489 feat(buzz): render Buzz DM notifications as messages, not posts
Follow-up to the Buzz-DMs-in-Notifications work: a Buzz DM on the Notifications
tab rendered like a public post and its actions didn't fit a DM.

- Render as a conversation: new RenderRelayGroupMessage draws a
  RelayGroupChannelHeader (titled by the other participant for a DM) above the
  message body — the group/DM analog of RenderChatMessage/RenderChannelMessage.
  NoteCompose routes a group-scoped kind-9 (ChatEvent with a RelayGroupChannel
  gatherer) and kind-40002 (StreamMessageV2Event) to it. The in-conversation
  chat screen renders via RefreshingChatroomFeedView, not NoteCompose, so its
  bubbles are unaffected.

- Reply goes into the conversation, not a public kind:1111: routeReplyTo now
  detects the gathering RelayGroupChannel (mirroring the Marmot/Concord cases)
  and opens the group/DM instead of falling through to Route.GenericCommentPost.

- Action row matches the card: a relay-group message (incl. a Buzz DM) hides
  Repost and Share — both would reference a membership-gated group event that
  non-members can't fetch, and a DM shouldn't be rebroadcast. Reply/like/zap stay.

Verified on emulator: the DM card shows the participant header + body + a
reply/like/zap-only row, and tapping reply opens the DM conversation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:03:56 -04:00
Vitor PamplonaandClaude Opus 4.8 eeeaab3c43 feat(buzz): surface Buzz DMs in the Notification feed
A Buzz DM is a relay-authoritative NIP-29 group whose messages carry no `p`
tag, so nothing made them eligible for the Notifications tab and nothing
fetched them app-wide (discovery was scoped to the open DM inbox, which only
pulls 44100 + 39000 — never the message bodies). Two halves fix that:

- NotificationFeedFilter now early-accepts a group chat message (kind-9 or
  kind-40002 — the deployed relay uses both) when it resolves to a `t=dm`
  channel whose 39000 participants include me, honoring the same "Messages in
  notifications" toggle and never notifying for my own message. LocalCache
  gains `getRelayGroupChannelForContent`, the read-only reverse-lookup this
  needs (same serving-relay-then-single-channel keying as the consume path).

- An always-on discovery (BuzzDmDiscoveryPreload) subscribes 44100 #p=me across
  joined workspaces into the new BuzzDmChannels registry and fetches each DM's
  39000 directory; BuzzDmJoinedChatTailFilterAssembler then keeps those
  channels' recent messages warm app-wide (reusing the joined-group #h tail),
  excluding hidden DMs. Both mount in LoggedInPage. This is what makes a Buzz
  DM show on Notifications / in push without opening the conversation.

Tests: BuzzDmChannels registry; and a LocalCache resolution test proving a
40002 and a kind-9 message both resolve back to their DM channel (and a
non-dm channel does not).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:03:56 -04:00
Claude 20384f5481 feat(buzz): add people to a channel/community + mint invite links
Three owner/admin add-people paths (the relay enforces the role gate):

- Channel add-member: an "Add member" FAB on the channel members screen
  opens a user-search dialog that publishes a NIP-29 kind-9000 put-user for
  the picked pubkey (plain member).
- Community add-member: an "Add people to this workspace" action in the Buzz
  community view publishes the Buzz relay-admin add-member command (kind
  9030) so the relay updates its NIP-43 membership list (13534). Wires the
  existing quartz RelayAdminAddMember/RemoveMember events into Account +
  AccountViewModel (addCommunityMember / removeCommunityMember).
- Invite-link mint: BuzzInviteMinter does the Buzz-specific, NIP-98-signed
  POST /api/invites on the relay host (via HTTPAuthorizationEvent + the
  trusted-relay OkHttp client) and returns {code, url, expires_at}; a
  "Create invite link" button surfaces the link with Copy / Share.

The user search is extracted into a reusable BuzzAddPeopleDialog shared by
the channel and community add flows. /api/invites is Buzz-proprietary (only
NIP-98 is standard); community add uses Buzz's 9030 admin command, since
NIP-43 itself has no owner-initiated add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 15:48:50 +00:00
vitorpamplonaandgithub-actions[bot] 8c0bee526a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-23 15:48:02 +00:00
Vitor PamplonaandGitHub 6a38d1330f Merge pull request #3678 from vitorpamplona/claude/home-screen-tab-row-padding-3yqyvq
Fix excessive vertical spacing in home feed when no live bubbles
2026-07-23 11:45:34 -04:00
Vitor PamplonaandGitHub ebe5b572a6 Merge pull request #3677 from vitorpamplona/claude/zap-devs-card-modernize-1dib1z
Redesign ZapTheDevsCard with gradient hero header and improved UI
2026-07-23 11:41:14 -04:00
Claude 9b9a3f12a9 fix(home): drop dead top padding when there are no live bubbles
The home feed always emitted a live-bubbles item followed by a 5dp
Spacer, even when the live section (live streams, geohash/ephemeral
chats) was empty — which is the common case. With no bubbles to draw,
that Spacer reserved dead vertical space at the very top of the list,
stacking on top of the LazyColumn's FeedPadding and the first note's own
top padding. The result was an oversized gap between the top-bar divider
and the first author's picture, most noticeable when the New Threads /
Replies tab row is hidden (single tab) so the divider sits right under
the top bar.

Move the row and its trailing Spacer inside a non-empty check so they
only render when there are actual bubbles. When the live section is
empty the item now collapses to zero height, bringing the home feed's
top gap in line with every other feed screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTFiQykXfCShHUCA3yM2pN
2026-07-23 15:28:44 +00:00
Claude 196aeaf974 feat: modernize the Zap the Devs donation card
Give the donation card a bolder, more exciting look:
- Add a gradient hero header (bitcoin orange → amethyst purple) with a
  glowing circular bolt badge and a larger, white title
- Round the card corners (16dp) and add elevation for a modern, floating feel
- Overlay the close button on the hero and keep the description, version
  credit, zap splits and donate button in a clean body section

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bM2CKE5Mnao4FG85HPuAV
2026-07-23 15:16:45 +00:00
Claude 853229a2c6 test(notifications): add Layer-3 end-to-end harness (amy + adb)
An agent-runnable script that drives the whole push-notification pipeline on a
real device/emulator: a second identity publishes each notification kind through
`amy` to the account on the phone, and the script reads back the notification
shade over `adb` to assert the right notification on the right channel — then
exercises cold-push metadata enrichment (title flips from raw pubkey to display
name in place) and dismiss-on-read (opening the note clears the tray entry).

Covers mention, reply, reaction, repost, picture, and DM; prints PASS/WARN/FAIL
and exits non-zero on any hard failure. tools/notification-e2e/{run.sh,README.md}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 14:26:41 +00:00
Vitor PamplonaandClaude Opus 4.8 a9fcd1c19c feat(buzz): Concord-style threading for Buzz chat replies
Concord threads chat messages with kind-1111 minichat comments, but Buzz relays
reject kind-1111 — so Buzz replies fell back to inline 40002 and rendered as flat
quotes, never opening a thread.

Buzz already threads over 40002 NIP-10 markers (matching block/buzz): a reply-marked,
non-`broadcast` 40002 is a thread reply; a `broadcast=1` one is an inline timeline
sibling. Teach Amethyst's minichat to speak that dialect:

- Add `isMinichatReply(event)`: kind-1111 CommentEvent, OR a Buzz 40002 thread reply
  (reply-marked, non-broadcast). Used by all three read sites so they agree.
- `LocalCache.computeReplyTo`: link a 40002 thread reply into its parent's replies.
- Timeline filter, minichat reply-count, and minichat feed now key on that predicate,
  so 40002 thread replies leave the timeline, get counted on the "N replies" chip, and
  render inside the minichat.
- Send: an INLINE reply on Buzz sets `broadcast=1` (stays inline); MINICHAT omits it
  (threads). `sendMinichatReply` posts a 40002 thread reply on Buzz instead of kind-1111.

Verified on device: a message shows an "N replies" chip, opens the Thread view with the
root + replies + composer, replies post and increment the count, and thread replies no
longer appear inline in the main chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:37:41 -04:00
Claude f9af111daf fix(notifications): gate chess notifications behind the debug feature flag
NIP-64 chess is debug-only for now (see the isDebug gate on the drawer entry),
so its notifications and settings channel shouldn't appear in release.

- ChessNotification.notify early-returns when !isDebug.
- NotificationChannels omits the Chess channel from the settings list (and thus
  never creates it) in release builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 13:29:29 +00:00
Vitor PamplonaandClaude Opus 4.8 f2dd8cd0f5 fix(buzz): keep DMs out of the Relay Groups discovery list
The Relay Groups discovery feed listed every kind-39000, including Buzz DM channels
— private 1:1 conversations that belong in the DM section, not a browsable group
list. Exclude channels whose 39000 carries the NIP-29 `hidden` tag (which Buzz sets
on t=dm channels) from both the constraint match and the "My Groups" path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:48:14 -04:00
Claude 7a01fe9fe5 fix(notifications): audit fixes — wakelock coverage, no resurrection, bounded enrichment
Deep-audit follow-up on the per-kind notification rework. Fixes two correctness
bugs and cuts the enrichment path's CPU/battery/IPC cost.

Correctness:
- Cold-push loss: the immediate notification post ran on a detached
  applicationIOScope coroutine OUTSIDE any wakelock (the dispatcher's wakelock
  was already released), so in Doze the CPU could sleep before it reached the
  tray. The enrichment wakelock now wraps the initial render too.
- Resurrection: enrichment re-posts a notification as metadata arrives; if the
  user read/dismissed the post in-app mid-window it came back seconds later.
  dismissNotificationForEvent now records the event id, postStandard/
  postConversation skip a dismissed id, and the enrichment window stops early
  once dismissed.

Performance / battery:
- Channel creation is cached per-process again (was 2 Binder IPCs on every post
  AND every re-render; now once, matching the original field-caching).
- Re-renders are debounced (250ms), collapsing the StateFlow initial-value burst
  and rapid relay arrivals into one rebuild instead of ~3+ back-to-back — cutting
  redundant bitmap loads/crops, notify() calls, and group-summary re-posts.
- Concurrent enrichment windows are capped (Semaphore of 4) so a push burst can't
  hold N simultaneous 25s relay windows + wakelocks + subscriptions.

Behavioral audit found no regressions: routing is a strict superset of the
original and all gates/deep-links are preserved.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:01:30 +03:00
mstrofnone a87187151a fix(namecoin): don't leak lookup exceptions through resolve()
`IElectrumXClient.nameShowWithFallback` implementations always throw a
`NamecoinLookupException` subtype (NameNotFound, NameExpired,
ServersUnreachable) on failure — the return-nullable signature is a
legacy of the old contract but no live impl (`ElectrumXClient`,
`CompositeNamecoinBackend`) actually returns null anymore.

`NamecoinNameResolver.performLookup` (the code path taken by the
non-detailed `resolve()` used by `Nip05Client.verify()`) has no
try/catch around that call. So any transport-level failure propagates
out of `resolve()` into `Nip05State.checkAndUpdate`, where the
`catch (e: Exception)` handler calls `markAsError()` and the NIP-05
verification badge in the UI turns into a red "Report" icon with the
`nip05_failed` string — regardless of whether the actual cause was
"servers all unreachable" or "resolver returned the wrong pubkey".

That collapses two very different failure modes into the same
red-icon state:

  1. Real verification failure (kind-0 nip05 doesn't match the
     Namecoin record's pubkey) — user should investigate.
  2. Transient network issue (custom ElectrumX server offline;
     `fallbackToDefaultElectrumx` disabled; carrier blocking
     port 50002/57002; Tor toggle on with Tor unreachable; etc.)
     — user should retry or check settings.

`resolveDetailed()` already has the try/catch and returns structured
outcomes (`NameNotFound` / `ServersUnreachable` / etc.). This change
brings `performLookup` in line so `resolve()` honours its documented
"returns null on any failure" contract, matching how
`expandImportsIfPresent` already treats `NamecoinLookupException`
during import-target fetches (best-effort → null).

Repro:

  * Namecoin Settings → set backend to "Namecoin Core RPC" with a
    localhost URL, no fallback. Or set a custom ElectrumX server
    that the phone can't reach (different network, cellular vs.
    Wi-Fi, etc.). Or leave `fallbackToDefaultElectrumx = false`
    with unreachable customServers.
  * Open any profile whose `nip05` field ends in `.bit` — you
    get the red icon (Error state) even though the record itself
    is fine on-chain and the pubkey matches.
  * After this fix: same setup surfaces null through
    `resolve()` → `verify()` returns false →
    `markAsInvalid()` → still red (Failed state, not Error), but
    no exception leaks. And the identical
    `NamecoinLookupException` handling on both the primary lookup
    and the import-target fetch means resolution behaves
    consistently regardless of which hop fails.

Tests: `NamecoinNameResolverExceptionTest` pins the contract with
7 hermetic cases covering NameNotFound / NameExpired /
ServersUnreachable / generic transport failure / CancellationException
propagation / and continued `resolveDetailed()` visibility of the
specific outcome subtype.

Verification:

    ./gradlew :quartz:verifyKmpPurity \
              :quartz:compileKotlinLinuxX64 \
              :quartz:compileKotlinIosArm64 \
              :quartz:spotlessCheck \
              :quartz:jvmTest --tests \
              'com.vitorpamplona.quartz.nip05.namecoin.*'

  BUILD SUCCESSFUL. All namecoin nip05 tests green; KMP purity + iOS
  + Linux native + spotless all clean.
2026-07-23 15:23:01 +10:00
Claude aba21c839f feat(notifications): per-kind, observable, beautifully-rendered push notifications
Rebuilds the Android tray-notification layer around a per-kind design system so
every Nostr notification reads at a glance and looks native to modern Android.

Framework:
- NotificationCategory: one table-driven entry per kind carrying its channel,
  accent color, monochrome status-bar icon, importance, group key, and settings
  icon. Reuses existing channel ids (no orphaned user settings) and adds
  Reposts/Media/Articles/Code/Badges channels, organized into system-settings
  channel groups (Messages/Social/Payments/Content/Developer/Games) so users can
  silence a single kind or a whole family.
- NotificationUtils: rewritten as a category-driven builder — postStandard
  (BigText, or BigPicture for media/badges) and postConversation (MessagingStyle
  for DMs/replies/chat/group). Adds setColor accents, circular avatars, colorized
  zaps, kept the NIP-30 emoji badge overlay, and setOnlyAlertOnce so enrichment
  updates replace silently.
- NotificationEnricher: makes notifications observable — renders immediately from
  cache, then subscribes to the involved npubs (author/zapper/chat members) and
  notes via userFinder/eventFinder, re-rendering in place as names, pictures,
  content, and post images arrive over a bounded relay window.

One file per notification (service/notifications/renderers/): DirectMessage,
GroupMessage (+welcome), Reply, Mention, Reaction, Zap (Lightning+nutzap+onchain),
Repost, Media, Article/Highlight, Code (git), Badge, Chess. EventNotificationConsumer
is now a slim policy dispatcher (account match + shared gates) delegating to them.

Closes push-vs-feed parity gaps: nutzaps (9321), onchain zaps (8333), reposts
(6/16), badge awards (8), and git PRs/updates (1618/1619) now render.

Dismiss-on-read is preserved (per-event id keying) so reading a post in-app
clears its tray notification. Adds monochrome status-bar drawables and the new
channel/title/group strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 05:16:08 +00:00
Claude 6d8a2f0950 docs(notifications): plan a per-kind beautiful push-notification redesign
Adds amethyst/plans/2026-07-23-push-notification-redesign.md: a rendering/UX
design for the Android tray notifications that gives each Nostr notification
kind (zap, reaction, reply, mention, DM, repost, nutzap, media, git, badge,
chess) its own accent color, status-bar icon, and notification style
(MessagingStyle / BigPictureStyle / colorized zap card), plus aggregation,
Conversations/Bubbles, and richer actions. Also documents the spec+builder
refactor, channel restructuring, icon/string work, parity gaps to close
(nutzap/onchain/repost/badge), a phased rollout, and testing.

Indexes the plan in amethyst/plans/README.md.
2026-07-23 04:18:18 +00:00
Vitor PamplonaandClaude Opus 4.8 b713f6061a feat(buzz): title a DM by its participant, hide forum/share for DMs
A Buzz DM channel showed the generic "DM" metadata name and offered Forum-threads
+ Share actions that don't fit a private 1:1 conversation.

- Title the DM by the OTHER participant's display name (from the 39000 inlined `p`
  tags), reactively resolved, falling back to the channel name until it loads.
- Hide the Forum/Threads button and the Share button when the channel is a DM
  (isBuzzDm): a DM has no forum, and its private, membership-gated naddr is
  meaningless to share.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:13:29 -04:00
Vitor PamplonaandClaude Opus 4.8 818f5c0bc7 feat(buzz): forum thread replies header, empty state, and inset-correct scaffold
Match Buzz's reference forum thread and fix insets:
- Add a "{N} replies" count header and a "No replies yet" empty state (flat root +
  replies, mirroring block/buzz's ForumThreadPanel — replies are NOT hierarchical).
- Use DisappearingScaffold instead of a plain Scaffold so the composer + list get the
  app's shared imePadding + navigation-bar insets (same as RelayGroupChatScreen); the
  composer is content at the bottom of the Column, not a Scaffold bottomBar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:01:38 -04:00
Claude 4421df9695 fix(buzz): keep Agent Console Costs + Personas live so they load after auth
The console did a single fetch on bind and read the cache back immediately,
which races the NIP-42 auth handshake: the warm-auth fetch can return before
the authenticated read actually delivers, so personas (30175) and turn
metrics (44200) landed in LocalCache a moment after the one-and-only
reloadFromCache — leaving both tabs empty with no way to refresh (the VM's
bind guard blocks a re-fetch on re-entry).

Adds a live subscription (startWatching/stopWatching, mounted for the
console's whole lifetime) for 44200 #p=me + 30175 authors=me on the
community relay; each arriving batch re-derives both tabs from LocalCache.
The relay re-serves the subscription once the connection authenticates — the
same path that unlocks the channel roster — so the console now populates on
its own instead of showing nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 03:35:39 +00:00
Claude bfc6862be4 feat(buzz): auto-invite mentioned non-members into a workspace channel
Mentioning someone who isn't yet a member of a Buzz workspace channel now
adds them (kind-9000 put-user) before the message is published, so the
`@`-mention resolves to a real member — mirroring Buzz's own composer, which
is how you pull a bot into a channel by naming it.

Gated on the sender being able to moderate (only a moderator can issue
kind-9000; it's a silent no-op otherwise), skips self and already-present
members, and is best-effort so a failed add never blocks the message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 03:29:27 +00:00
Claude 7ae96ae3ce feat(buzz): presence dots, collapsible sections + unread + stars, canvas editing, bot "Working…" indicator
Brings the Buzz community + member screens closer to Buzz's own clients:

- Presence dots (BuzzPresenceState → online green / away amber) overlaid
  on DM-row and member-list avatars; offline/unknown shows nothing rather
  than implying we track a peer we don't.
- Community view sections (Channels / Forums) are now collapsible, each
  channel row carries an unread dot (relayGroupChannelHasUnreadFlow) and a
  star toggle; starred channels float to the top. Stars persist
  device-globally (BuzzChannelStars + BuzzChannelStarPreferences), mirroring
  the joined-workspaces store.
- Canvas editing: the canvas screen gains an edit mode that publishes a
  fresh kind-40100 CanvasEvent to the channel's host relay (last-write-wins);
  the top-bar canvas button now shows on any Buzz channel so an empty one can
  be created.
- Bot "Working…" indicator: a process-wide BuzzAgentActivityState, fed by a
  members-screen observer (24200) subscription, lights a live "Working…" line
  next to an agent currently emitting frames; tapping opens the community's
  Agent Console. Owner-scoped by nature (frames are #p=owner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 03:28:48 +00:00
Vitor PamplonaandClaude Opus 4.8 bb88a18474 feat(buzz): use the full chat composer for forum replies
The forum thread's reply field was a bare OutlinedTextField. Replace it with the
same rich composer chats use (EditFieldRow): emoji + @mention suggestions, media
attach, drafts, and the typing indicator, with the standard send button styling.

Reuse works by making ChannelNewMessageViewModel.createTemplate() protected/open
and adding ForumReplyNewMessageViewModel, which overrides only the built event so
a send publishes a kind-45003 ForumCommentEvent scoped to the open thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:21:42 -04:00
Vitor PamplonaandClaude Opus 4.8 dbf9b29e25 feat(buzz): render forum posts as threads, not chat bubbles
Buzz forum roots (kind 45001) and comments (45003) were consumed onto the chat
timeline (consumeBuzzTimelineEvent → addNote), so a forum post showed up as a
kind-9 chat bubble and replies leaked into the chat feed.

Route them like the content they are:
- 45001 → the group's Threads collection (addThread) via a new consumeBuzzForumPost,
  so it surfaces in the forum/Threads view; 45003/45002 are now store-only.
- ThreadRow renders a ForumPostEvent (body-as-heading, no title) alongside kind-11.
- The Threads REQ (RELAY_GROUP_THREAD_KINDS) now also fetches 45001/45003.
- A forum-type channel (channel_type=forum) opens the Threads view directly from
  the community Forums section instead of the chat view.
- New BuzzForumThreadScreen: the root post + its 45003 replies + a reply composer
  (ForumCommentEvent.build). Own replies aren't re-delivered on the open sub, so a
  send restarts the REQ to re-fetch the thread.

Verified on device: forum posts list as threads, open to root+replies, replies post
and appear; the forum channel's chat no longer shows the posts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:10:57 -04:00
Vitor PamplonaandClaude Opus 4.8 681bd1b389 fix(buzz): recognize NIP-43 community membership in the channel gate
A Buzz community member (kind 13534 roster / 8000-8001 deltas) can read and post
across every channel on the relay, but `RelayGroupChannel.membershipOf()` read
only the per-channel NIP-29 roster (39001/39002), so a community member/admin who
wasn't explicitly added to a private channel resolved as NONE and was wrongly
gated (Join lock, hidden composer).

Add `BuzzCommunityMembership`, a per-relay registry fed by the relay-signed NIP-43
events, and consult it as a Buzz-only fallback in `membershipOf` (mapped to MEMBER
only, never channel ADMIN, to avoid over-granting per-channel moderation). Wire
`LocalCache.consume` for kinds 13534/8000/8001 to update the registry (LWW on
created_at) and poke the relay's channels so the gate re-renders live.

This complements the open-channel fix: open channels no longer require membership
at all, and members of private channels are now recognized community-wide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:59:47 -04:00
Vitor PamplonaandClaude Opus 4.8 709ab6565b fix(buzz): don't gate posting/Join on the roster for open channels
A Buzz workspace channel you can participate in still showed a Join lock and
"invite-only — you need an invite to post": `RelayGroupChannel.membershipOf()`
reads only the per-channel NIP-29 roster (39001/39002), and the UI treated the
`closed` metadata tag as invite-only.

But Buzz's write gate (buzz-relay `check_channel_membership`) is *member OR
visibility=="open"*: an open (non-`private`) channel accepts kind-9 from any
authenticated relay member with no per-channel join, and Buzz stamps `closed`
onto every channel — so `isClosed()` says nothing about who may post; only
`isPrivate()` reflects the write ACL.

Add `RelayGroupChannel.requiresMembershipToPost()` (Buzz open channels don't;
standard NIP-29 relays always do) + `canPost(pubkey)`, and route the composer,
threads FAB, top-bar Join button, and discovery Join button + invite-only badge
through it. Standard NIP-29 relays are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:59:47 -04:00
Claude e86983893c feat(buzz): sectioned community view — Channels, Forums, inline DMs, then Agent Console
Reworks the Buzz relay's Community view to mirror Buzz's own sidebar
ordering instead of stacking the DM + Console entries on top of the
channels:

- Channels and Forums are now separate sections, split by the relay-signed
  39000 `channel_type` (stream vs forum); DM-typed channels are excluded
  from both (they belong to the DM section). Adds isBuzzForum() and the
  forum/stream type constants alongside the existing DM reader.
- Direct Messages moves below the channels and renders inline: the most
  recent conversations (avatar + name + last-activity time), a New-message
  action in the section header, and a "See all N" row into the full inbox
  when there are more than fit.
- Agent Console drops to a single footer card at the very bottom.
- Section headers get a consistent modern style (primary-colored labels
  with optional trailing actions), replacing the old top-stacked action
  cards. BuzzImportRow gains an onOpen tap target so a channel row opens
  the chat while keeping its Add-to-list affordance.

Vanilla NIP-29 relays are unchanged (flat channel directory, no
forums/DMs/console).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-23 01:51:12 +00:00
Vitor PamplonaandGitHub 56f8968172 Merge pull request #3676 from vitorpamplona/claude/privacy-options-modernize-9l51qs
Refactor privacy settings UI to use composable components
2026-07-22 21:06:51 -04:00
Claude 7c4090a800 feat(settings): modernize Privacy, Profile UI, Home Tabs & Calendar Reminder screens
Bring four settings screens onto the SettingsSection card kit used by the
UI Preferences / Security Filters screens, and drop the last Save/Cancel
flow in favor of auto-save + back-button-only navigation.

- Privacy Options: remove SavingTopBar + TorDialogViewModel staging; bind
  controls directly to TorSettingsFlow.tryEmit (the existing debounced
  propertyWatchFlow already persists changes), so edits save on change and
  the screen has only a back arrow. Rebuild the body from SettingsSection
  cards: a segmented Tor-engine tile, an Orbot-port block, a live preset
  picker row, and per-usage switch tiles.
- Profile UI & Home Tabs: reskin the ad-hoc rows into SettingsSection
  cards with SettingsSwitchTile / SegmentedChoiceTile (Home Tabs keeps the
  "can't disable the last tab" guard via the enabled flag).
- Calendar Reminders: swap the hand-rolled TopAppBar for
  TopBarWithBackButton and reskin into a SettingsSection card with a
  segmented lead-time selector, preserving the CalendarReminderPrefs writes
  and WorkManager schedule/cancel side-effects.
- Shared kit: make SegmentedChoiceTile internal for reuse and add a compact
  title-only SettingsSwitchTile overload with enabled support.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JvrwLYbGVJQhabfZS7sBy
2026-07-23 00:57:09 +00:00
Claude e83fa73cc3 fix(buzz): warm-auth DM/console reads + reconnect-on-join to authenticate the socket, and user-search for New DM
The Buzz relay gates its `#p=me` reads (44100 member-added, 30622 DM
visibility) and the 39002 group roster behind NIP-42. Two gaps kept
those empty even after joining a workspace:

- DM list and Agent Console used a plain paged fetch, which returns
  empty on an `auth-required` CLOSED. Switch both to the warm-auth
  `fetchAllWithHooks(pendingOnAuthRequired = true)` path the import
  already used, so they authenticate on the CLOSED and retry.

- NIP-42 sends its AUTH challenge once, on connect. When the socket was
  already open before the user joined (the relay is in their lists and
  connected at startup), that challenge was spent while the relay was
  still not first-party, leaving the persistent group-roster subscription
  refused — so the channel showed a "Join" lock. A join makes the relay
  first-party (AuthCoordinator.isFirstParty); force a reconnect on a new
  join so the relay re-challenges and the connection authenticates,
  unlocking the roster and every other #p=me read on the shared socket.

Also reworks the New Buzz DM recipient picker: instead of only accepting
a raw npub/hex, it now offers a typeahead user search
(LocalCache.findUsersStartingWith) that surfaces this workspace's channel
members first. Pasting an npub/hex still works as an escape hatch for
someone not yet in the local cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 23:35:32 +00:00
Claude 94d5f55f93 Merge remote-tracking branch 'origin/main' into claude/buzz-repo-analysis-7k54ga 2026-07-22 22:58:51 +00:00
Claude e1f732ee38 refactor(buzz): nest DMs + Agent Console per-community, drop drawer entries
Buzz DMs and the Agent Console are community-scoped (a DM is a t=dm relay group on
one community's relay; agents/telemetry live on community relays), so they don't
belong as global drawer entries. Remove the AGENT_CONSOLE + BUZZ_DMS nav items and
reach both from within a community instead: the Buzz relay's group screen now shows
"Direct Messages" and "Agent Console" cards scoped to that one relay.

- Route.BuzzDmList / BuzzNewDm / AgentConsole now carry a relayUrl.
- BuzzDmListViewModel / BuzzNewDmViewModel / AgentConsoleViewModel take a single-relay
  scope (and mark it joined + pre-approve NIP-42 so the #p=me discovery authenticates).
- Removed the settings-catalog AgentConsole search entry (no longer global).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 22:58:41 +00:00
Vitor PamplonaandGitHub 7a9457e88b Merge pull request #3675 from vitorpamplona/claude/concordcommunitylistevent-relay-loading-s95nxm
Bootstrap Concord communities from pinned bottom-bar tabs
2026-07-22 18:52:09 -04:00
Claude 5166a5ac69 fix(concord): load pinned communities from their own relays
A Concord community pinned to the bottom bar wouldn't load at all when its
private kind-13302 joined-communities list wasn't already cached: the tab and
its server screen stayed blank. The list often lives only on the community's
own relays (Armada/Vector publish it there, never to the user's outbox), and
the only fetch that looked beyond the outbox — importConcordCommunities — was
triggered solely from the Concord hub and never queried the community's relays.

Carry each pinned community's bootstrap relays on its BottomBarEntry.Concord
tab (captured from the joined-list entry at pin time) and:

- importConcordCommunities now takes extra relays and folds in the relays saved
  on every pinned Concord tab, so the list is found where it actually lives;
- ConcordChannelPreload bootstraps app-wide: it fetches the list for any pinned
  community we don't yet know, so the tab and server screen fill in without the
  user ever opening the hub.

Once the list folds into the cache, ConcordChannelListState.liveCommunities
already surfaces it reactively (verified by a new late-arrival test) and the
plane preload picks the community up — so a late-arriving list with no local
backup now updates the tab and the Concord Channels screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSiSXaHMEpuo3gcDuTZ24u
2026-07-22 22:40:46 +00:00
Claude abdcd9c39d fix(relay): show relay groups when NIP-11 is unreachable; reconnect on trust change
Two fixes for the "connected over clearnet, receiving 39000s, but nothing shows"
case on a Cloudflare-fronted relay (blocks the plain HTTP NIP-11 GET while serving
events over the socket):

- Relay group-list screen no longer hard-depends on NIP-11. The display gate
  needs the relay's `self` key (from NIP-11) to show relay-signed groups; when the
  NIP-11 fetch fails (FAIL_TO_REACH_SERVER — Cloudflare resets the GET), fall back
  to the dominant 39000 signer on that relay as its de-facto signer, so its groups
  render while a stray user-published 39000 (different author) stays filtered. Also
  re-fetch NIP-11 when the relay is marked Trusted (moved to clearnet), busting the
  cached over-Tor error (new Nip11CachedRetriever.invalidate).

- RelayProxyClientConnector now reconnects the transport-flipped relay immediately
  when a relay-classification set changes (e.g. marking it Trusted → clearnet).
  Previously only TorRelaySettings changes counted, so a newly-trusted relay sat
  out its Tor-earned backoff before re-dialing. Scoped to onlyIfChanged (no
  resetBackoff), so only the flipped relay skips its delay and the rest of the
  pool's backoff is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 22:35:06 +00:00
Claude 7837f6b233 refactor(buzz): unify Buzz into relay-groups — drop redundant tab/button, add Tor→clearnet
Three cleanups collapsing parallel Buzz UI into the relay-group machinery a Buzz
workspace already is (a NIP-29 relay + its groups):

1. Tor→clearnet escape hatch: when a relay won't load over Tor (non-onion, not
   already trusted) after a grace period, the relay screen offers "Use clearnet",
   which adds it to the kind-10089 Trusted Relay List — connected over clearnet
   even while Tor stays on. Targets Cloudflare-fronted relays that block Tor exits.

2. Merge "Import Buzz" into "Browse": one operation, two filters (public 39000
   directory vs your 44100 memberships). The relay group-list screen now detects a
   Buzz relay (dialect or NIP-11 software) and folds in your membership-scoped
   channels with Add / Add all; the standalone import screen/route + the second
   browse-screen button are gone. Adding still calls follow() → kind-10009.

3. Remove the redundant Buzz Workspaces tab: a workspace IS a relay group, so the
   parallel list is gone. The Agent Console and Buzz DM inbox move to their own
   drawer entries (NavBarItem.BUZZ→AGENT_CONSOLE + BUZZ_DMS); the invite screen's
   post-browser button now opens the relay's group screen. Deletes
   BuzzWorkspacesScreen/ViewModel + BuzzBrand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 22:11:09 +00:00
Claude 08f581f29c feat(buzz): import your workspace channels from a relay in Find groups
Buzz workspaces expose no public group directory (membership is server-side, no
kind-10009 join), so the generic "browse a relay" directory comes back empty for
them. Add a membership-scoped importer: from Find groups, enter the Buzz relay
you're already on and tap "Import your channels". It warm-authenticates the relay
(NIP-42), reads the relay's kind-44100 member-added notifications addressed to you,
fetches each channel's 39000 metadata, and lists your non-DM workspace channels
with Add / Add all.

Adding calls account.follow(channel) — a public kind-10009 group tag — so the
channel then flows through the existing relay-group machinery for free: it shows in
the Messages list (inline or grouped-by-community per relayGroupViewMode) and loads
at boot via the always-on relay-group subscription. No separate registry needed.
Entering the importer also joins + pre-approves NIP-42 for the relay so discovery
is served.

New: Route.BuzzRelayImport, BuzzRelayImportViewModel, BuzzRelayImportScreen, a
Find-groups entry point, and strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 21:23:01 +00:00
Claude 2621fed63c fix(buzz): hide DM + Console cards until there is a workspace
The DM inbox and Agent Console both query only your joined/dialect workspace
relays (BuzzWorkspaces + BuzzRelayDialect), so with zero workspaces they have no
relays to hit and can only ever be empty — yet the hub showed both as prominent
top-level cards, implying they're global features when they're workspace-derived
(a DM is a per-community NIP-29 group; the console aggregates the fleet on your
community relays). Gate both behind having >=1 workspace so the empty state is
just the "join a workspace" prompt; once you have one they sit above the list as
the cross-workspace inbox / fleet view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:46:56 +00:00
David KasparandGitHub 005ffa6b58 Merge pull request #3674 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-22 21:27:43 +01:00
Claude 06a64d01af fix(buzz): label the tab "Buzz Workspaces" instead of "Workspaces"
Renames buzz_workspaces_title, which names the tab in the drawer + bottom bar
and the screen's top bar. "Workspaces" alone was ambiguous (Concord also uses
that word); "Buzz Workspaces" makes the feature clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:26:36 +00:00
Claude aab52d31c9 fix(buzz): make the Agent Console Attest FAB round like the app's other FABs
It was an ExtendedFloatingActionButton (icon + "Attest" label), which Material 3
renders as a rounded-rectangle pill — visually out of step with the circular
FloatingActionButton(shape = CircleShape) used by the sibling chat/relay-group/
concord create actions. Switch to the same circular icon FAB, keeping the label
as the icon's contentDescription for accessibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:08:56 +00:00
Claude 9087565d98 fix(buzz): surface Workspaces in the drawer Feeds list
NavBarItem.BUZZ was added to the catalog and the bottom-bar settings picker but
never to DrawerFeedsItems, the list the left navigation drawer's Feeds section
renders. So Buzz Workspaces could be pinned to the bottom bar yet was missing
from the drawer alongside Relay Groups and Concord. Add it between them, matching
the chats grouping order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:05:28 +00:00
Claude 45d18ab330 feat(buzz): resolve kind-20001 collision so BitChat + Buzz presence coexist
Kind 20001 is claimed by both BitChat's GeohashPresenceEvent and Buzz's
PresenceUpdateEvent. EventFactory's flat when(kind) could only route one, so
Buzz presence never materialized (parsed as a geohash event) in either
direction — this was the deferred "EventFactory collision".

Disambiguate inside the shared 20001 branch by BitChat's required `g` (geohash)
tag: present -> GeohashPresenceEvent, absent -> PresenceUpdateEvent. Verified
against the Buzz Rust ground truth (buzz-sdk build_presence_update + the relay's
synthesize_presence read form): Buzz presence carries the status in content plus
a `status` tag (client) or a `p` tag (relay-synthesized), never a `g` tag, and
BitChat presence always carries `g` with empty content. Both inbound parse
(EventDeserializer) and outbound signing (EventAssembler) route through this
factory, so one guard fixes both.

Make it usable, not just parseable: add BuzzPresenceState (process-wide latest
online/away/offline per subject, mirroring BuzzTypingState), a
PresenceUpdateEvent.subjectPubKey() accessor (the `p` tag or the author), and a
LocalCache branch that records presence and drops the ephemeral without storing
it. Tests cover both Buzz wire shapes, the BitChat guard, and latest-wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 19:55:19 +00:00
Claude 4d0e3d5b46 feat(buzz): auto-auth joined workspaces, post-join UX, DM add-member, leave + UI polish
- Auto-authenticate relays for Buzz workspaces the user explicitly joined:
  their read-only #p=me channel/DM discovery is otherwise not first-party, so
  the p-gated 44100/30622 reads were never served and workspaces stayed empty.
- Invite screen: two-state hand-off — join + pre-approve NIP-42, launch the
  in-app window.nostr browser, then point back to the workspaces hub.
- DM inbox: add-member action (npub/hex dialog → kind-41011) alongside hide.
- Workspaces hub: leave-workspace overflow on each header.
- Elevate the Buzz surface with a shared BuzzBrand gradient design kit — hero
  masthead with live workspace/channel stats, cohesive across screens.
- Drop the dead kind-41001 DM-conversation path: the deployed relay never
  emits a queryable 41001, so BuzzDmRegistry is trimmed to the 30622 hidden
  set and LocalCache stores DmCreatedEvent without registry bookkeeping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 19:06:46 +00:00
vitorpamplonaandgithub-actions[bot] a37cc94481 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-22 18:29:22 +00:00
Claude f8bb53848b Merge remote-tracking branch 'origin/main' into claude/buzz-repo-analysis-7k54ga 2026-07-22 18:28:40 +00:00
Vitor PamplonaandGitHub 1e874239b8 Merge pull request #3673 from vitorpamplona/fix/no-client-tag-for-external-signers
fix(signer): never stamp our client tag on someone else's template
2026-07-22 14:26:27 -04:00
Claude 7205d62de2 fix(buzz): warm-auth-then-retry on DM open so the OK channel id survives a cold connection
openBuzzDm reads the relay-assigned channel id from the DM-open OK response, but
a bare publish to an auth-required Buzz relay on a cold (not-yet-authenticated)
connection is rejected `auth-required` — the write path doesn't re-send after the
async NIP-42 AUTH lands (proven against the live relay via the amy CLI). The app's
persistent, proactively-authed connection usually masks this, but opening a New DM
before discovery has connected that relay would race it.

Port the amy fix: on an `auth-required` ack, warm the connection with a
pendingOnAuthRequired read (lets the AuthCoordinator finish the handshake), then
retry the publish and re-read the channel id from the OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 18:12:46 +00:00
Vitor PamplonaandClaude Opus 4.8 636dd2afe7 fix(signer): never stamp our client tag on someone else's template
The NIP-89 client tag says "this app composed this event", so it belongs only
on templates Amethyst authored. NostrSignerWithClientTag was applied at the
account level, which meant it also fired on every event we sign on behalf of
an external client.

That is wrong twice over. It misattributes the event, and — because the tag is
appended before signing — it rewrites the exact bytes the caller is about to
have hashed into an id. NIP-07 callers routinely re-check the returned event
against the template they submitted, and block/buzz compares tags outright
(web/src/shared/lib/nostr-signer.ts):

    JSON.stringify(actual.tags) === JSON.stringify(expected.tags)

so joining a Buzz community from the in-app browser failed with "The NIP-07
extension returned an invalid signed event". Probed live over the WebView
devtools protocol: kind, created_at, content and pubkey all round-tripped
intact and only tags differed, by exactly the ["client","Amethyst"] we append.

Add NostrSigner.withoutClientTag() and use it at the two boundaries where the
template belongs to someone else:

- the napplet broker, covering napplets, nSites and web apps over NIP-07
- Nip46SignerState, where we act as another client's bunker — the same defect,
  and quieter, since that client never learns why its event changed underneath
  it

Amethyst's own events are untouched and still carry the tag. Unwrapping keeps
everything layered below (metering, NIP-13 mining) and leaves pubKey alone.
There is no NIP-55 provider surface to fix; we are only ever the client there.

Verified against Buzz's own four acceptance conditions after the change:
pubkey matches, sameUnsignedEvent true, id and sig present — and the invite
join then succeeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:57:39 -04:00
Claude ef1b5b6bc2 feat(buzz): workspace + DM discovery via 44100/39000, matching the live relay
The earlier discovery layer read the NIP-29 joined list (kind-10009) and
kind-41001 — neither of which the deployed relay uses, so a joined workspace
rendered nothing. Rework it to the model live testing confirmed.

Enabling layer — persist joined workspaces:
- commons BuzzWorkspaces: process-wide set of joined workspace relays (Buzz
  membership is server-side, so there's no join event to rebuild from). Joining
  also marks the relay a Buzz dialect. Unit-tested.
- BuzzWorkspacePreferences: device-global DataStore that restores the set at
  startup (so the app connects + authenticates + discovers on cold start) and
  mirrors changes. Eager init in AppModules. BuzzInviteScreen now `join`s.

Quartz:
- BuzzChannelMetadata: read the relay's `t` channel-type tag ("stream"/"forum"/
  "dm") and a DM's inlined `p` participants off kind-39000.

Discovery (both hubs now source from the relay's real signals):
- BuzzWorkspacesViewModel: fetch + live-subscribe kind-44100 member-added
  notifications (#p=me) across joined relays → my channels; fetch each channel's
  39000 metadata; keep the non-DM ones. BuzzWorkspacesScreen unions this with the
  NIP-29 joined list.
- BuzzDmListViewModel: same 44100 discovery, kept where 39000 `t`=dm (participants
  from the metadata `p` tags), minus the 30622 hidden set — replaces the dead
  kind-41001 path.
- Account.openBuzzDm returns the relay-assigned channel id from the OK response
  (`response:{channel_id}`); BuzzNewDmViewModel opens the chat from it instead of
  polling 41001.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 17:46:05 +00:00
David KasparandGitHub 9380de7813 Merge pull request #3672 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-22 17:53:10 +01:00
vitorpamplonaandgithub-actions[bot] 8247d49afa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-22 16:46:03 +00:00
Vitor PamplonaandGitHub 4eb1231270 Merge pull request #3669 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-22 12:43:23 -04:00
Vitor PamplonaandGitHub 70bcbd0bdb Merge pull request #3666 from davotoula/fix/addressable-author-loader-cme-alternative
Stop ConcurrentModificationException in AddressableAuthorRelayLoaderSubAssembler
2026-07-22 12:43:16 -04:00
Vitor PamplonaandGitHub 5cfb7c3454 Merge pull request #3670 from vitorpamplona/claude/ci-android-test-report-plugin-k9p0kb
Replace abandoned Android test report action with mikepenz/action-junit-report
2026-07-22 12:42:34 -04:00
davotoulaandgithub-actions[bot] adf9b68c7a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-22 16:32:32 +00:00
Claude b86bc5b798 ci: replace abandoned android-test-report-action with mikepenz/action-junit-report
asadmansr/android-test-report-action@v1.2.0 is a Docker action whose
Dockerfile is `FROM ubuntu:18.04` + `apt-get install python` (Python 2).
Ubuntu 18.04 (bionic) is end-of-life, so its apt archives are now
unreliable and Python 2 has no installation candidate — the image rebuild
runs on every CI invocation, takes ~8 minutes, and has started hard-failing
the test-and-build-android job (`E: Package 'python' has no installation
candidate`). The action was last released in 2020 and is unmaintained, and
it was referenced by a movable tag rather than a pinned commit.

Swap it for mikepenz/action-junit-report (actively maintained, Apache-2.0,
JS action — no Docker rebuild), pinned to the v6.4.2 commit SHA. Use
annotate_only so it needs no `checks: write` permission and keeps working on
pull requests from forks; fail_on_failure keeps the job red when a unit test
fails, matching the previous step's behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMtpSuyr8FBh2ZQ22BV4ZP
2026-07-22 16:25:23 +00:00
davotoula e6f6edd29b update cs,sv,de,pt 2026-07-22 17:23:43 +01:00
davotoula 6ed955f0cf docs: language updates 2026-07-22 17:23:04 +01:00
Claude 2c1ec97bb4 refactor(buzz): single /invite/ substring gate in RichTextParser
Both the Concord and Buzz invite checks re-scanned the word for "/invite/".
Fold them under one guard — the two shapes stay disjoint (Concord carries a
`#` fragment, Buzz does not), so only one branch decodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 16:00:29 +00:00
Vitor PamplonaandGitHub 52ce590505 Merge pull request #3668 from vitorpamplona/claude/quartz-jvmtarget-17-v0g1u3
Downgrade JVM target from 21 to 17 in quartz module
2026-07-22 11:44:46 -04:00
Claude 5f954c0e04 build(quartz): target JVM 17 instead of JVM 21
Lower the quartz module's Kotlin jvmTarget for both the JVM and Android
compilations from JVM_21 to JVM_17, broadening the range of runtimes that
can consume the published library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1Hn61gQJ1joUznESzUHU4
2026-07-22 15:42:02 +00:00
Claude 7aca3da631 feat(buzz): intercept Buzz invite links into the in-app window.nostr browser
A Buzz workspace invite (`https://<host>/invite/<token>`) is redeemed over HTTP,
and the Buzz web app already drives that flow (policy consent + NIP-98 claim)
through `window.nostr`. Amethyst's existing in-app browser (NappletBrowserActivity
via FavoriteAppLauncher.launchUrl) injects an origin-scoped window.nostr backed by
the user's signer into any https origin — so routing Buzz invites there lets the
SPA claim membership as the user's key, with no native re-implementation of the
consent UI.

Mirrors the Concord invite intercept, at all three entry points, keeping every
other link external by default:
- Deep link: AndroidManifest intent-filter for *.communities.buzz.xyz/invite/ +
  a `buzzInviteRoute()` branch in MainActivity.uriToRoute.
- Tapped in-content link: a BuzzInviteLinkSegment classified in RichTextParser
  (commons) + a ClickableBuzzInviteLink that routes to Route.BuzzInvite.
- Search bar: a branch in SearchBarViewModel.directRouteResolver.

Route.BuzzInvite → BuzzInviteScreen confirms the workspace (host + role parsed by
the quartz BuzzInviteLink), marks its relay as a Buzz dialect, and opens the
in-app browser at the invite URL to finish joining. The matcher is host-agnostic
(BuzzInviteLink.parse), so self-hosted Buzz invites intercept via tap/search even
without a manifest filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 15:37:37 +00:00
Vitor PamplonaandGitHub 6440445938 Merge pull request #3667 from vitorpamplona/fix/video-decoder-budget-and-aspect-ratio
fix(video): decoder-budget exhaustion and black bars on live streams
2026-07-22 11:35:53 -04:00
Vitor PamplonaandClaude Opus 4.8 edf30489d0 fix(video): size the player box so live streams stop rendering black bars
A live stream opened for the first time drew ~90px of black above and below
the picture. The video surface itself was correct 16:9; the box enclosing it
was not. Measured on a Pixel 9 emulator: container [0,274][1080,1062] (788px
= StreamingHeaderModifier's 300.dp cap) holding a TextureView of
[0,364][1080,972] (608px = 16:9 at 1080 wide), centred, so (788-608)/2 = 90px
per side.

Two independent causes, both needed fixing:

ContentWarningGate takes a `modifier` but drops it for anything not flagged
sensitive — the non-sensitive path emits `content()` bare. ZoomableContentView
was routing mediaSizingModifier() through exactly that parameter, so for
ordinary media the sizing never reached the layout at all. With no height
constraint the player stretched to whatever ceiling enclosed it and
letterboxed the frame inside. Apply the sizing to the inner Box, which is
always emitted.

Even applied, the ratio was unknown on a first play: a NIP-53 stream carries
no imeta `dim`, and MediaAspectRatioCache is only filled once the decoder
reports a size. The miss was frozen for the whole visit because the cache was
a plain LruCache read during composition, which triggers no recomposition when
it later fills — hence the bars vanishing only on a *second* visit to the same
stream. Back cache entries with snapshot state so a composition-time read
updates, and default an unknown video to 16:9 so the first layout already
lands in the right place.

VideoView keeps reading the cache inside remember() on purpose, with a comment
explaining why: making it observable there flips the ratio mid-playback, which
both adds an aspectRatio and emits an extra Spacer, and restructuring children
around a live AndroidView strands the player on a stale surface — the video
redraws at native size in the corner while layout bounds still look correct.

Verified on a cold cache: container and TextureView are both
[0,274][1080,882], against a header ending at 274 — zero gap. Feed image and
video layouts unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:24:30 -04:00
Vitor PamplonaandClaude Opus 4.8 041a4c1c71 fix(playback): enforce the decoder budget when acquiring players
MediaCodec instances are a per-process resource with a hard per-device
ceiling — the Android emulator's c2.goldfish.h264.decoder declares
`concurrent-instances max="4"`. Past that, MediaCodec.start() fails with
NO_MEMORY, MediaCodecRenderer reports "Failed to initialize decoder", and
the video surfaces to the user as "can't load". Opening a live stream after
scrolling a few feed videos reproduced this reliably; killing the process
made the same stream play, since that released every held codec.

The device ceiling was already computed by SimultaneousPlaybackCalculator,
but only reached ExoPlayerPool as `poolSize`, which governs how many idle
players are *retained*. The acquire path was uncapped
(`coldPool.poll() ?: builder.build(context)`), and MediaSessionPool held a
hardcoded LruCache(10) of sessions, each pinning a checked-out player. So a
4-decoder device would happily hold 10.

Enforce the budget where players are handed out:

- Track live decoders process-wide, counting checked-out and warm players
  (cold ones have been stop()'d and hold none). The counter and the pool
  registry are global because PlaybackService builds one pool for direct
  traffic and another for Tor-proxied traffic; a per-pool budget let the
  app hold twice the ceiling.
- Before a cold or fresh player is handed out, reclaim headroom by demoting
  warm players to cold — own pool first, then siblings. Warm entries are a
  scroll-back cache, so they are the right thing to give up under pressure.
- Size the session cache from the same device budget, keeping the previous
  10 as an upper bound so capable devices are unaffected.

Verified on the emulator: 9 codec allocations across a session with zero
NO_MEMORY and zero decoder-init failures, where allocation #5 previously
died.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:24:30 -04:00
Claude bb39fb29fc feat(buzz): invite-link redemption + live-interop fixes (amy)
Validated against the production relay wss://amethyst.communities.buzz.xyz
by joining and running a full DM round-trip. Adds the join primitive and
fixes the interop gaps that live testing surfaced.

- quartz: BuzzInviteLink — parse `https://<host>/invite/<token>` (relay-signed
  base64url payload → community/role/expiry). A Buzz invite is NOT a NIP-29
  code; it is redeemed over HTTP against the tenant host. Unit-tested with a
  real token; rejects the Concord `/invite/<naddr>#…` shape (no collision).
- cli: `amy buzz join <invite-url>` — the real 3-step claim: GET /api/join-policy,
  POST /api/invites/accept-policy, then NIP-98-signed POST /api/invites/claim.
  Proven live (status: joined, role: member).
- cli: Context.publish now authenticates-then-retries on an `auth-required`
  relay (warm the connection with a pendingOnAuthRequired REQ, then re-publish)
  — the write path had no NIP-42 handling, so every Buzz write was rejected.
- cli: Buzz reads (dm list / read / console / personas) use the auth-aware
  drain (pendingOnAuthRequired).
- cli: `dm open` surfaces the relay's synchronous OK `response:{channel_id}` —
  the authoritative DM channel id (the relay assigns it; it is not polled).
- cli: `dm list` rewritten to the relay's actual discovery — kind-44100
  member-added notifications (#p=me) filtered to the kind-40099 `dm_created`
  channels. The deployed relay does NOT emit kind-41001.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 14:56:37 +00:00
Claude c79e7d361f feat(buzz): wire Buzz direct messages end-to-end (app + amy)
A Buzz DM is a relay-authoritative NIP-29 group whose h/id is a
relay-generated UUID, so its timeline reuses the whole relay-group chat
stack unchanged. This adds the missing discovery + product layer:

- commons: BuzzDmRegistry — process-wide registry fed by LocalCache from
  the relay-signed DmCreatedEvent (41001) and per-viewer DmVisibilityEvent
  (30622); tracks conversations (channel id -> participants/relay) and the
  viewer's hidden set. Unit-tested.
- LocalCache: record 41001/30622 into the registry on consume (was
  store-only).
- Account: openBuzzDm (41010), hideBuzzDm (41012), addBuzzDmMember (41011).
  The relay assigns the channel UUID and confirms via 41001 — we never
  mint it.
- Android: BuzzDmListViewModel (two-phase fetch: discover 41001/30622 #p=me,
  then fetch each DM's 39000-39003 roster so the shared composer's member
  gate passes), BuzzDmListScreen (inbox), BuzzNewDmScreen (publish 41010,
  await the 41001, jump into the shared RelayGroupChatScreen). Reached from
  a Direct Messages card on the Workspaces tab.
- CLI: amy buzz dm list/open/hide/add-member, mirroring buzz-cli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 14:09:45 +00:00
davotoula 71620380fb update KotlinJpsPluginSettings version 2026-07-22 14:41:16 +01:00
davotoula 53a823e1b2 Code review:
- private monitor, and correct the commit() KDoc
- replace atomics handshake with a single monitor
- flag userFinder re-entrancy assumption in commit() KDoc
2026-07-22 14:41:16 +01:00
davotoula 2e4afbf75e fix: stop ConcurrentModificationException in AddressableAuthorRelayLoaderSubAssembler
`activeSubscriptions` was a plain LinkedHashSet iterated (`SetsKt.minus`) and
mutated (`clear`/`addAll`) with no synchronization, while `invalidateFilters()`
ran synchronously on whatever thread called subscribe/unsubscribe.

Those callers are genuinely concurrent: `ComposeSubscriptionManager` invokes
`invalidateKeys()` after releasing its own lock, and
`LifecycleAwareSubscription`'s 30s grace-period unsubscribe fires on a
`Dispatchers.Default` worker while composition subscribes from elsewhere.
Hence the reported `ConcurrentModificationException` on
`DefaultDispatcher-worker-70`.

This is the only member of `EventFinderFilterAssembler.group` that implements
`IEoseManager` directly; its two siblings extend `BaseEoseManager`, whose
`invalidateFilters` hands off to `BundledUpdate` and is therefore never run on
the caller's thread nor concurrently with itself.

Fix, matching that existing pattern and avoiding locks:

- Route through `BundledUpdate`. `BasicBundledUpdate` holds `isProcessing`
  under a Mutex, so only one body runs at a time — the concurrent
  iterate-vs-mutate window is gone by construction. It also moves the
  `allKeys()` scan and per-stub `getOrCreateUser` off the caller thread, which
  `ComposeSubscriptionManager` documents as "called by main. Keep it really
  fast."
- Hold the state in an `AtomicReference<Set<...>>` of immutable snapshots
  swapped with `exchange()`, plus an `AtomicBoolean` teardown flag, mirroring
  the `AtomicReference` + CAS idiom in `FilterIndex`/`BanStore`.
- `bundler.cancel()` cannot stop a body already executing (no suspension
  points), so `destroy()` flags first and an in-flight body compensates by
  releasing what it just acquired. Double-unsubscribe is a no-op.

No locks are introduced; the hot path is strictly cheaper than before.

Tested: the new concurrency test reproduces the exact production failure
against the pre-fix code (`ConcurrentModificationException` alongside the
overlap detector) and passes after. Full :amethyst suite green (941 tests).

Note the pre-existing `UserFinderQueryState` identity-equality churn is
deliberately NOT addressed here: the set-diff never converges because each run
allocates fresh wrappers. It widens this race window but is an independent
defect needing its own design decision.
2026-07-22 14:40:14 +01:00
Vitor PamplonaandGitHub c9e8cd4e0c Merge pull request #3665 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-22 09:25:40 -04:00
vitorpamplonaandgithub-actions[bot] 1c9f8f8d54 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-22 13:24:31 +00:00
Vitor PamplonaandGitHub d72d7df0a0 Merge pull request #3664 from nrobi144/worktree-feat+desktop-polls
feat(desktop): NIP-88 polls (render, vote, create) + "Polls" search facet
2026-07-22 09:21:27 -04:00
Claude c788c3caa5 feat(buzz): workspace→channels shell, forum composer, attestation persistence
- Workspace shell: BuzzWorkspacesScreen now groups joined Buzz-dialect groups
  BY RELAY into workspace→channels (a Buzz workspace IS a relay/tenant, per
  buzz-core relay_url_authority), each an expandable section with its channels
  and a + to the relay's directory (Concord-style community→channels).
- Forum composer (45001): the Threads-tab FAB opens BuzzForumPostScreen on a
  Buzz relay, publishing a ForumPostEvent (mirrors build_forum_post) instead of
  the vanilla kind-11 thread a NIP-29 relay uses.
- Held-attestation persistence: BuzzAttestationPreferences mirrors
  BuzzHeldAttestations to the device DataStore and reloads at startup,
  re-verifying each credential against its agent key (drops tampered entries).

Deliberately NOT built: job/huddle composers — jobs (43xxx) and huddles (48xxx)
have no builder in buzz-sdk (reserved kinds), so a composer would encode an
unconfirmed schema. Presence (20001) skipped per request (EventFactory collision
with GeohashPresenceEvent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 13:06:55 +00:00
nrobi144andClaude Opus 4.8 b2adb157ff feat(desktop): NIP-88 polls (render, vote, create) + "Polls" search facet
Adds full NIP-88 poll support to Amethyst Desktop and a search content-type
filter for polls.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:09:25 +03:00
Claude 12aa9fe954 feat(cli): first-class 'amy buzz' commands for block/buzz workspaces
Thin assembly over quartz + commons (no new protocol in cli/):
- buzz post RELAY GID <text>  — publish a kind-40002 stream message (h-scoped)
- buzz read RELAY GID         — drain the recent human-visible timeline (9/40002/40099)
- buzz attest AGENT           — sign a NIP-OA OwnerAttestation offline, print the auth tag
- buzz console [--relays]     — drain kind-44200 turn metrics (#p=me), NIP-44-decrypt,
                                and aggregate via the shared commons AgentFleetAggregator
- buzz personas [--relays]    — list my kind-30175 personas (newest per slug)

Join/leave/create reuse 'amy relaygroup' (Buzz workspaces are NIP-29 groups). Wired
into Main dispatch + usage; README command table + ROADMAP updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 05:01:25 +00:00
Claude 54364731a6 feat(buzz): 'Workspaces' bottom-nav tab + hub screen
Gives Buzz a first-class navigation identity instead of hiding inside the generic
Relay Groups list. NavBarItem.BUZZ (Route.BuzzWorkspaces) is a pinnable bottom-nav
destination (added to NavBarCatalog + BottomBarCategories + the preloader when).

BuzzWorkspacesScreen is a modern hub: an Agent-Console hero card up top, then the
user's joined groups filtered to Buzz-dialect relays as cards with a colored
monogram avatar, live name/host/member-count and an unread dot, plus an inviting
empty state that routes to Browse groups. It reuses the always-on relay-group state
subscription (no per-screen fetch) and the same RelayGroupChannel the chat opens.

Also fixes the open-chat-tail test for the added 20002 typing kind
(RELAY_GROUP_OPEN_TAIL_KINDS), and documents typing + the hub in the buzz README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:27:59 +00:00
Claude a0d50c69f6 feat(buzz): live typing indicators (kind 20002)
Brings Buzz to typing-indicator parity with Concord, verified against buzz-core
kind.rs (KIND_TYPING_INDICATOR=20002, requires_h_channel_scope=false).

- commons BuzzTypingState: process-wide, lock-guarded channel->typist->heartbeat
  registry with stale pruning + future-clamp (6 unit tests).
- LocalCache records 20002 heartbeats into it (still no feed row; own typing
  filtered in the UI).
- RELAY_GROUP_OPEN_TAIL_KINDS requests 20002 on the open channel's live tail only
  (ephemeral, scoped to the room on screen, never the joined fleet).
- Account.sendBuzzTyping fires a throttled heartbeat to the host relay; the
  composer sends it on text change (gated to Buzz relays).
- BuzzTypingIndicator: an animated three-dot '… is typing' row above the composer
  that slides in/out and ages typists out on a timer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:15:50 +00:00
Claude 5d9efc800a docs(buzz): correct reaction/delete claim — Buzz ALL_KINDS accepts 7/5/9005/1984/emoji
Earlier text wrongly said Buzz has no reaction kind. buzz-core/src/kind.rs ALL_KINDS
includes KIND_REACTION(7), KIND_DELETION(5), KIND_NIP29_DELETE_EVENT(9005)/GROUP(9008),
KIND_REPORT(1984), and emoji list/set (10030/30030); requires_h_channel_scope(7) is
false. So the shared chat action sheet's react/delete/report already work against Buzz.
Only zaps (no 9734/9735) aren't relay-stored (payment still works). Typing/presence are
accepted by Buzz but not yet rendered by Amethyst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 03:57:51 +00:00
Claude f869834e9d docs(buzz): sync README with canvas viewer + edit composer; note no reaction kind
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:26:43 +00:00
Claude fb7bdf3057 feat(buzz): edit composer for stream messages (kind 40003)
Closes the edit loop: Amethyst already renders 40003 edit overlays but could
not create them. ChannelNewMessageViewModel gains a Buzz edit mode
(editBuzzMessage/clearBuzzEdit) — the next send publishes a kind-40003 edit
targeting the original instead of a new 40002, kept minimal to mirror Buzz's
build_edit (h + e + content). An 'Edit' action on the message sheet is gated to
my own 40002 messages (the kind alone marks it Buzz) and threads to the composer
via an optional onWantsToEditBuzz callback through the shared chat feed
(default-null, so no other chat surface is affected). EditFieldRow shows an
editing banner with a cancel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:20:34 +00:00
Claude f31e7a4c0b feat(buzz): workspace canvas viewer (kind 40100)
Adds a read surface for the Buzz canvas, which was consumed into overlay state
but had no UI. BuzzWorkspaceState gains a canvasUpdates flow; BuzzCanvasScreen
(Route.BuzzCanvas) renders the newest 40100 markdown for a channel and recomposes
when a newer revision lands. Entry point is a Dashboard icon in the relay-group
top bar, shown only on a Buzz-dialect relay once a canvas has arrived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:12:17 +00:00
Claude 4a4baf6a23 feat(buzz): NIP-OA agent auth at connect (hold + inject auth tag)
Tier 1 connect path. BuzzHeldAttestations (commons) stores the OwnerAttestations
this device received, keyed by the agent pubkey each authorizes (verify-passing
only). AuthCoordinator.buzzAugmented appends the owner-signed auth tag to an
account's NIP-42 AUTH event when that account's key has a held attestation and
the relay speaks the Buzz dialect — and only that account's AUTH, never the
Concord stream-key AUTHs sharing the template, and never on non-Buzz relays.
So an un-enrolled agent key gets virtual membership while its owner stays a member.

AgentAttestationScreen gains a 'Hold an attestation' section (paste the auth tag
JSON, verified against the current account, stored/removed) alongside the existing
owner-side issuance. Store is in-memory for now — persisting per-account is a
follow-up. 4 store tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:34:56 +00:00
Claude bce544e8c3 feat(buzz): persona create/edit from the agent console
Tier 3 write flow. AgentPersonaEditScreen + AgentPersonaEditViewModel publish
a Buzz Agent Persona (NIP-AP kind:30175) to the workspace's Buzz-dialect relays
(falling back to the owner outbox). The Personas tab gains a 'New persona' row
and each card is tappable to edit.

On edit the existing PersonaEvent is loaded from LocalCache so fields the form
does not expose (name pool, respond-to allowlist, parallelism) are preserved
rather than dropped on republish; the slug (d tag) is locked once chosen and
validated against the relay's grammar (^[a-z0-9][a-z0-9_-]{0,63}$).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:28:30 +00:00
Claude 0ea5d0e876 feat(buzz): widen group REQ to richer kinds + kind-aware renderers
Read-path interop. Two changes:

1. RelayGroupFilterBuilders: BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS now also
   requests canvas (40100), forum posts/votes/comments (45001-45003), agent
   jobs (43001-43006), and huddle lifecycle (48100-48103) — all h-scoped, so
   the same #h group REQ returns them. Previously only 40002/40003/40008/40099
   were requested, so LocalCache could consume these kinds but they never
   arrived for a group feed.

2. RenderBuzzNotes: kind-aware rows dispatched from ChatMessageCompose —
   RenderBuzzDiff (40008: monospace, horizontally-scrollable diff with a
   repo/commit/file header instead of prose-wrapped gutters), RenderBuzzActivityRow
   (job + huddle lifecycle as centered system narration — huddles MUST be caught
   here since their content is JSON), and RenderBuzzForumVote (45002). Forum
   posts stay as plain chat bubbles (their content is already plain text).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:23:26 +00:00
Claude 5137b92046 feat(buzz): NIP-OA attestation issuance in the agent console
Adds AgentAttestationScreen, reached from an "Attest" FAB on the console.
The owner enters an agent pubkey (npub/hex) and optional AttestationConditions
(kind, created_at before/after), and OwnerAttestation.sign() produces the
signed auth tag, displayed for copy to hand to the agent operator out-of-band.

Entirely offline (nothing is published) and gated on a raw private key: the
NIP-OA signature covers a hashed commitment rather than a Nostr event, so
NIP-46 bunker / NIP-55 external-signer accounts get an explanation instead of
the form. Inputs are validated (kind 0-65535, unix bounds 0-u32, npub/hex key,
no self-attestation) with human-readable errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 00:36:15 +00:00
Vitor PamplonaandGitHub 72b8df7f6d Merge pull request #3663 from vitorpamplona/claude/sqlite-query-performance-bh2j27
SQLite query scaling: FTS contentless, tag-merge, pooled statements
2026-07-21 19:38:22 -04:00
Claude 2124409025 docs(buzz): document the agent-owner console in the buzz README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:35:42 +00:00
Claude 1b76dd250e feat(buzz): live observer telemetry tab in agent console
Adds a third Observer tab streaming ephemeral agent telemetry frames
(ObserverFrameEvent, kind:24200, p=owner) live from every Buzz-dialect
relay via subscribeAsFlow. Frames are decrypted (frame:telemetry only),
deduped across relays, sorted newest-first, and bounded to a 200-row ring.
The live REQ is opened only while the Observer tab is on screen (started
in a DisposableEffect, cancelled on dispose and in onCleared) because
observer frames are never persisted by relays or LocalCache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:34:29 +00:00
Claude 201aafb5bf feat(buzz): agent-owner console with cost + personas tabs
Read-only owner dashboard over the AI-agent fleet. AgentConsoleViewModel
fetches turn metrics (kind:44200, p=owner) and personas (kind:30175,
authored by owner) from the Buzz-dialect relays plus the owner outbox set,
decrypts the NIP-44 metric payloads with the owner signer (cached by event
id), and derives fleet + per-agent totals via the pure AgentFleetAggregator.

AgentConsoleScreen renders a Costs tab (fleet total, per-agent spend/tokens/
turns/sessions, estimated-total warning) and a Personas tab (display name,
model, runtime, provider, system prompt). Wired as Route.AgentConsole with a
settings-catalog entry under App settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:26:58 +00:00
Claude 81fa81612b feat(commons): agent fleet cost aggregator for the owner console
Pure aggregation of decrypted NIP-AM turn metrics (kind:44200) into per-agent
and fleet token/cost totals — the data core of the agent-owner console.

Correctness that makes the numbers trustworthy: turns group by (agent,
sessionId); within a session `cumulative` is the monotonic running total, so the
max cumulative per field is the authoritative session total (robust to dropped
turns), falling back to summing per-turn deltas only where no cumulative exists —
per field, so a cumulative that omits costUsd still gets cost from deltas.
Delta-sums that include a deltaReliable=false turn flag the fleet estimate.

10 tests cover cumulative-not-summed, missing-turn recovery, delta fallback,
per-field mixing, multi-session/multi-agent rollup, sessionless singletons, and
the unreliable-estimate flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:14:09 +00:00
Claude c79b795477 fix(buzz): address adversarial review findings (channel-swap, verified marks, engram tombstone)
Three-reviewer pass over the branch. Fixes, most-severe first:

HIGH — channel-instance swap froze open screens. The subclass-upgrade design
replaced a group's RelayGroupChannel with a Buzz-typed instance on dialect
discovery, but screens/feeds/composers capture the instance for life, so all of
them kept rendering the orphan (frozen feed; composer stuck on kind 9). Removed
BuzzWorkspaceChannel entirely; RelayGroupChannel is a single stable type again.
Buzz-only overlay state (edit + canvas) now lives in BuzzWorkspaceStates, a
registry keyed by the channel UUID — dialect discovery no longer touches object
identity. The swap also silently dropped all relay-signed state (name, members,
admin status, pins, threads) and demoted the confirmed host; gone with the swap.

HIGH — wrong thread-root got messages relay-rejected AFTER the draft was
destroyed. A reply to a direct reply derived root=parent (buzzThreadRoot null on
a collapsed reply), which Buzz's ancestry validator rejects. Fallback is now
buzzThreadRoot() ?: buzzThreadReply() ?: parent.id. Also: minichat (kind 1111)
replies in Buzz channels are relay-rejected, so they're gated off for Buzz relays.

HIGH — pre-create defeated stray-redirect and marked the dialect off unverified
input. consumeBuzzTimelineEvent no longer pre-creates the channel; attachment
goes through the shared NIP-29 path (with its stray protection), and the dialect
is marked only off a VERIFIED event (markBuzzIfVerified) — a hostile relay can no
longer flip what the composer sends.

MEDIUM — engram tombstone divergence: a memory body missing `value` decoded as a
null tombstone (a deletion) where Buzz rejects it. `value` is now required (no
default); added NIP-AE slug-grammar validation. Pinned by test.

MEDIUM — unbounded edit overlay: pruneOldMessagesChannel now prunes overlay
entries for reaped messages. Overlay is keyed by channel id so own offline edits
(null relay) apply too.

Also: 40002 inbound reply linkage in computeReplyTo (we emit thread markers we
couldn't read); registered-but-unhandled 40901/40902/48001 now consumed;
unconditional Buzz-kind widening (kills the history-cursor-skip); stream mention
dedup; persona empty-list omission for content-hash parity; thread-marker
positional guard; edit-note-loading blank-row fallback.

Full amethyst unit suite + affected quartz suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:00:52 +00:00
Claude 6bc16314e8 test(amethyst): update open-tail kind expectation for the Buzz bootstrap
The open-channel tail deliberately carries the Buzz timeline kinds now (the
dialect-detection bootstrap); pin the new contract in the existing builder test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 22:24:27 +00:00
Claude c85f51c40a refactor(buzz): typed thread/p tags in composer + dialect bootstrap + live join/leave proof
Review feedback: the 40002 composer branch hand-rolled raw arrayOf tags. Now:

- New shared `buzz/threading` package: `buzzThread(root, parent)` builder verb
  emitting Buzz's exact thread_tags wire form (["e",root,"","root"] +
  ["e",parent,"","reply"], collapsing when parent==root; the empty relay slot
  is deliberate — MarkedETag.assemble's arrayOfNotNull would slide the marker
  into the relay slot) and `buzzThreadRoot()/buzzThreadReply()` positional
  readers. The forum verbs now delegate to it (streams and forum comments share
  thread_tags in buzz-sdk), and the composer uses buzzThread + the typed
  pTag(PTag(...)) verb instead of raw arrays.

- Dialect bootstrap fix: the single-group open-channel REQ now always includes
  the Buzz timeline kinds. Without this, an undiscovered Buzz relay was a
  chicken-and-egg: fleet subs only widen after BuzzRelayDialect marks the
  relay, but the mark comes from consuming a Buzz kind no filter asked for.
  Opening a channel is explicit one-group intent, so the wider ask is cheap and
  matches nothing on vanilla relays; fleet-wide subs stay dialect-gated
  (both behaviors pinned in BuzzTimelineKindsTest). The live relay's NIP-11
  ("Buzz Relay", supported_extensions=[nip-er,nip-pl]) is a future
  connect-time marker.

- Live proof of the full workspace lifecycle against the running Buzz relay
  (BuzzRelayLiveInteropTest, 3/3 green): discover the channel via its
  relay-signed 39000 (queryable by #d), join with NIP-29 kind-9021 from a
  second member, post a 40002 after joining, and leave with kind-9022 — the
  exact Quartz events Amethyst's group UI sends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 22:16:39 +00:00
Vitor PamplonaandGitHub 35e61e1d98 Merge pull request #3662 from vitorpamplona/fix/docs-license-badge-and-skill-md
docs: fix license badge and refresh stale SKILL.md
2026-07-21 17:38:07 -04:00
Vitor PamplonaandClaude Opus 4.8 6730100853 docs: fix license badge and refresh stale SKILL.md
The README license badge label read "Apache-2.0" while LICENSE, PRIVACY.md
and every source header are MIT. Only the static label text was wrong (the
shields.io endpoint auto-detects), but it is the license on the front page.

SKILL.md had drifted from the codebase since the Kotlin DSL migration:

- All Gradle references pointed at Groovy `build.gradle` / `settings.gradle`;
  the repo is `.gradle.kts` throughout. Converted the snippets to Kotlin DSL
  and matched the repo's existing `getByName("release")` style.
- The plugins block listed `jetbrainsKotlinAndroid` (gone) and omitted
  `serialization` and `googleKsp`.
- compileSdk is 37, not 35. Added a pointer to libs.versions.toml so the
  number has a source of truth rather than drifting again.
- The client-tag section told readers to create
  `nip01Core/tags/clientTag/TagArrayBuilderExt.kt` and edit both `build()`
  functions in TextNoteEvent. That file already exists at
  `nip89AppHandlers/clientTag/`, and the tag is now applied centrally by the
  NostrSignerWithClientTag decorator — so rebranding is a one-constant edit
  to CLIENT_TAG_NAME.
- Default relays pointed at `quartz/src/main/java/...`, a path that does not
  exist in the KMP layout; they live in commons `defaults/`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:27:39 -04:00
Claude 54ecae50af feat(amethyst): render Buzz channels natively + compose kind-40002 messages
Completes the visible half of Buzz workspace support:

- Composer: BuzzWorkspaceChannel branch (checked before its RelayGroupChannel
  parent) sends the native kind-40002 stream message with the group's h tag,
  Buzz thread markers on replies (["e",root,"","root"] + ["e",parent,"","reply"],
  collapsing when parent is the root — mirrors thread_tags in buzz-sdk) and a
  p notify to the parent author, with the same hashtag/url/quote/emoji/imeta
  enrichment as every other channel type.
- Rendering: kind-40099 system messages render as centered system lines (like
  NIP-28 admin rows) from the relay-signed payload; kind-40003 edits render as
  an overlay — the newest edit's content replaces the stale original with an
  "(edited)" marker, recomposing via the channel's editUpdates flow.
- LocalCache correction pinned by test: edits are overlays, never timeline rows
  (Buzz's own CHANNEL_TIMELINE_CONTENT_KINDS excludes 40003) — a 40003 is
  stored and recorded in the channel's edit map but no longer attached to the
  timeline, so it can't render as a duplicate message.

All Buzz unit tests green; app compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 21:21:38 +00:00
Claude 7903834efd feat(commons): Buzz workspaces as NIP-29 groups in the Buzz dialect
Surfaces block/buzz workspace channels inside the existing NIP-29 relay-group
experience — one group model, dialect-aware rendering, zero impact on vanilla
groups (kind-filtered feeds can never receive Buzz kinds by accident).

- BuzzRelayDialect (commons): per-relay capability registry. Event-shape
  detection — the first Buzz-only kind consumed from a relay marks it; vanilla
  relays never serve those kinds, so no false positives. NIP-11 marking can be
  layered on later.
- BuzzWorkspaceChannel (commons): sibling of RelayGroupChannel (now `open`)
  holding Buzz-only channel state: the kind-40003 edit overlay (never render
  superseded text as current) and the newest kind-40100 canvas. Kind-9 chat and
  kind-40002 stream messages share ONE timeline per group so mixed-dialect
  conversations stay whole.
- LocalCache: consumes every registered Buzz kind (previously all fell into the
  "Event Not Supported" branch). Timeline kinds attach to the group's channel,
  materialized dialect-aware with an in-place upgrade (note migration) when the
  dialect is discovered after the channel was first created as plain NIP-29.
  Addressables store replaceably; the rest store as queryable regular events;
  ephemeral signals (typing 20002, observer 24200, huddle reaction 24810,
  pairing 24134) mark the dialect but are deliberately not persisted.
- RelayGroupFilterBuilders: group-chat REQs widen their timeline kind set with
  40002/40003/40008/40099 only for marked relays; vanilla NIP-29 REQs unchanged.

Tests cover dialect detection + materialization, the plain-channel upgrade with
timeline migration, newest-edit-wins overlay ordering, and that filter builders
extend kinds only on marked relays. Full amethyst unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 20:58:11 +00:00
Claude d7125b8b08 test(quartz): live interoperability test against a running Buzz relay
Adds an env-gated jvmTest (BUZZ_RELAY_WS / BUZZ_MEMBER_SK / BUZZ_OWNER_SK; CI
skips it) that drives the real block/buzz relay booted from its own compose +
cargo build, with members enrolled via buzz-admin. Verified green against the
running relay:

- NIP-42 member auth, and NIP-OA/NIP-AA agent auth: a brand-new un-enrolled
  agent key authenticates using only our owner-signed `auth` tag — the relay's
  NIP-OA membership fallback accepts the Quartz-produced attestation.
- Channel lifecycle: kind:9007 create via the existing NIP-29 CreateGroupEvent
  (Buzz channels are NIP-29 groups), kind:40002 publish, REQ round-trip with a
  byte-identical echo (same id), EOSE.

Interop lessons encoded in the test + README: tenancy is host-bound (connect
with the bound Host or the WS upgrade 404s), `h` values must parse as UUIDs,
and the channel row must exist before channel-scoped kinds are accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 20:17:58 +00:00
Claude f5d22ce720 feat(quartz): implement the full Buzz-custom protocol surface (~77 event kinds)
Adds Quartz-native models for the entire block/buzz custom kind space, each as
an idiomatic per-NIP package (Event + KIND companion + tags/ + write-DSL +
read accessors, mirroring nip88Polls) and registered in EventFactory. Standard
NIP-29/34/51/42/43 kinds Buzz reuses are left to their existing Quartz classes.

Coverage:
- Agent identity: Persona 30175, Team 30176, Managed Agent 30177, Agent Profile 10100
- Agent telemetry (encrypted): Observer 24200, Engram 30174 (HMAC d-tag derivation
  pinned to Buzz reference vectors), Turn Metric 44200 (prior commit)
- Workspace overlays: Reminders 30300, Push Lease 30350, DM Visibility 30622,
  Workspace Profile 9033, Identity Archival 9035/9036/8002/8003/13535,
  Channel Window 39005/39006, relay admin 9030-9032, moderation 9040-9044, 42000
- Messaging/collab: stream 40002-40100 (+sidecars), DMs 41001/41010-41012,
  jobs 43001-43006, forum 45001-45003, workflow 30620/46001-46031,
  notifications 44100/44101, presence 20001/20002, huddles 24810/48100-48106,
  pairing 24134, audit 48001, media 49001, read-state (NIP-RS helpers on 30078)

All schemas confirmed against the authoritative Rust in a local block/buzz
checkout (buzz-core / buzz-sdk / buzz-relay), not the outdated prose NIPs.
Kinds only reserved-but-unbuilt in Buzz (jobs, workflow lifecycle, some
stream/sidecar/audit kinds) are modeled tolerantly and flagged in KDoc + README.

Kind conflicts with existing Amethyst classes are implemented but deliberately
NOT registered in EventFactory (incumbent keeps dispatch): 9041 (GoalEvent),
20001 (GeohashPresenceEvent), 39005 (GroupPinnedEvent), plus 49001 (Buzz marks
it non-wire) and 30078 read-state (reuses AppSpecificDataEvent).

156 Buzz tests pass; full quartz jvmTest green (no regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 19:54:05 +00:00
Claude 1757ef4513 feat(quartz): restructure Buzz to per-event layout + Agent Turn Metric (NIP-AM)
Addresses review feedback on the initial Buzz commit:

- Drop the central `BuzzKinds` registry (un-idiomatic). Each event now
  declares its own `const val KIND` on its companion and is wired into
  `EventFactory`, matching the nip88Polls-style per-NIP layout used across
  Quartz (Event + tags/ + TagArrayBuilderExt write-DSL + TagArrayExt readers).

- Implement Agent Turn Metric (NIP-AM, kind:44200): an encrypted per-turn
  token-usage/cost record published by an agent to its owner. content is a
  NIP-44 v2 ciphertext of AgentTurnMetricPayload (camelCase), between the
  agent (author + `agent` tag) and owner (`p` tag); either party decrypts.

- Verify against a vector generated by Buzz's OWN code rather than a
  transcribed schema: a small generator on the real buzz-core emits a signed
  44200 event with deterministic keys; AgentTurnMetricVectorTest dispatches it
  through EventFactory, NIP-44-decrypts it, and asserts the payload — proving
  end-to-end interop. Fixture + generator committed under jvmTest resources.

Schemas confirmed against buzz-core/src/agent_turn_metric.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 19:04:15 +00:00
Claude a54c808763 feat(quartz): add Buzz protocol kind registry + Owner Attestation (NIP-OA)
Introduces a top-level `buzz` package modelling the block/buzz protocol in
Quartz. Buzz is a NIP-29-family relay-group workspace (relay is the source of
truth, plaintext content, server-side membership) that layers an agent +
workspace vocabulary on top, so this package models only the Buzz-custom
extensions and reuses the existing NIP-29/34/51/42/43 classes for the standard
kinds.

- BuzzKinds: the full kind registry mirroring buzz-core `kind.rs`, annotated
  with where each standard kind already lives in Quartz.
- Owner Attestation (NIP-OA): the owner-signed `auth` tag that lets agents act
  as first-class members. Commitment = SHA-256("nostr:agent-auth:" + agent +
  ":" + conditions), BIP-340 Schnorr-signed by the owner; conditions grammar
  (kind=/created_at</created_at>, canonical decimals, u16/u32 bounds) matches
  buzz-sdk `nip_oa.rs` exactly.
- Tests pin Buzz's own published known-answer vector as a cross-implementation
  compliance check: our preimage hash equals Buzz's SHA-256 and our verifier
  accepts Buzz's reference signature.

Confirmed against the authoritative Rust (buzz-core/buzz-sdk), not the prose
NIP drafts, which lag the implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 18:47:12 +00:00
1460 changed files with 97272 additions and 7291 deletions
@@ -212,9 +212,25 @@ Before presenting results, **scan the missing English strings** for two red-flag
Also **audit existing `<plurals>` resources** for two anti-patterns:
1. **`quantity="one"` items that hardcode the literal `1`** (instead of using a `%d` / `%1$d` placeholder) — broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.).
2. **`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. **everything except Arabic (`ar`) and Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and all the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
2. **`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. everything except **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and most of the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item.
> ⚠️ **Latvian is the trap here — do NOT strip its `zero` items** (we nearly did, 2026-07-22). `lv` has an integer-bearing `zero` category that covers far more than 0: `select(0)`, `select(10)` and `select(11)` all return `zero` (the rule is `n % 10 = 0` or `n % 100 = 11..19`). So a Latvian `<item quantity="zero">` is *live code on the majority of counts*, and it must read as a normal plural form ("%1$d minūšu"), **not** as "no items" wording. An earlier version of this skill claimed only `ar` and `cy` had `zero`, which flagged all ~40 correct Latvian entries as dead and would have deleted working translations.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item. (This is why `zero` is the wrong tool even where it exists: in `lv` it does not mean "zero".)
**Verify, don't recall.** Before asserting any locale's category set, check it against CLDR rather than memory:
```bash
python3 -m venv /tmp/cldr && /tmp/cldr/bin/pip -q install babel
/tmp/cldr/bin/python -c "
from babel import Locale
for c in ['en','lv','ar','cy','cs','de','sv','pt_BR','ru','pl']:
r = Locale.parse(c).plural_form
print(c, sorted({r(n) for n in range(0,10001)}), 'select(0)=', r(0), 'select(10)=', r(10))
"
```
Across the 56 locale dirs this repo ships, **only `ar-rSA` and `lv-rLV`** have an integer-bearing `zero`.
Flag and offer to fix:
@@ -240,15 +256,16 @@ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*
done
```
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)** — those three are skipped below, so a hit is a genuine bug. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
# Skip Arabic and Welsh — they natively use the zero category.
# Skip Arabic, Latvian and Welsh — they natively use the zero category.
# (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.)
case "$f" in
*values-ar*|*values-cy*) continue ;;
*values-ar*|*values-cy*|*values-lv*) continue ;;
esac
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
@@ -312,9 +329,10 @@ When adding or proposing **`<plurals>`** entries, follow these rules:
- Polish (`pl`): `one`, `few`, `many`, `other`
- Russian (`ru`): `one`, `few`, `many`, `other`
- Arabic (`ar`): `zero`, `one`, `two`, `few`, `many`, `other`
- Latvian (`lv`): `zero`, `one`, `other` — its `zero` is **not** "no items"; it covers 0, 10, 1119, 20, 30, …
- German / Swedish / Brazilian Portuguese: `one`, `other`
- When a missing string contains a count placeholder and is conceptually a singular/plural pair, **flag it before translating** — it may belong as a `<plurals>` resource rather than a single `<string>`. Surface this to the user before proposing translations.
- **Do not use `quantity="zero"` outside Arabic (`ar`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those two languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
- **Do not use `quantity="zero"` outside Arabic (`ar`), Latvian (`lv`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those three languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. Conversely, **never delete an existing `zero` item from `ar`/`lv`/`cy`** — there it is live. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
```kotlin
val label = if (count == 0) {
stringRes(R.string.foo_no_items, dateLabel)
@@ -377,4 +395,5 @@ When adding translated strings to locale files:
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic, Latvian and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
- **Reporting Latvian `quantity="zero"` entries as dead code** — `lv` has a real, integer-bearing `zero` category covering 0, 10, 1119, 20, 30, … so those entries fire on *most* counts. An earlier version of this skill excluded only `ar`/`cy` from the zero audit and flagged all ~40 correct `values-lv-rLV` entries; acting on that would have deleted working translations. Confirm any locale's category set against CLDR (the babel snippet in Step 4) before calling a `zero` item dead.
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.12.6` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.1` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.12.6"
quartz = "1.13.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.12.6
com.vitorpamplona.quartz:quartz:1.13.1
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.12.6"
quartz = "1.13.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+17
View File
@@ -0,0 +1,17 @@
# Keep the Docker build context small + reproducible: exclude VCS metadata,
# Gradle/build outputs, IDE files, and local caches. The build stage runs a
# fresh `./gradlew :geode:installDist` inside the image, so nothing under any
# build/ directory needs to travel with the context.
.git
.github
**/build/
.gradle/
**/.gradle/
.idea/
*.iml
.kotlin/
**/.kotlin/
local.properties
# Docs + non-geode app modules aren't needed to build the relay, but the Gradle
# settings reference every module, so we can't drop the module source trees;
# only their build artifacts (covered above) are excluded.
@@ -0,0 +1,73 @@
name: Resolve Release
description: >-
Resolve a bump workflow's target tag and that release's real published state.
Companion to assert-stable-release: this one FETCHES the facts, that one
ENFORCES them. Split so the enforcement stays a pure function of its inputs.
Handles both entry points of the bump workflows:
- workflow_run -> tag comes from the triggering run's head_branch
- workflow_dispatch (manual recovery) -> tag comes from the input
In both cases draft/prerelease are read back from the GitHub API rather than
inferred, so a draft or prerelease can never slip through to a third-party
package repo just because the trigger payload lacked the flags.
inputs:
tag:
description: "Release tag to resolve (e.g. vX.Y.Z)"
required: true
github_token:
description: "Token used to read the release via the GH API"
required: true
outputs:
tag:
description: "The resolved tag, verbatim (e.g. v1.13.1)"
value: ${{ steps.resolve.outputs.tag }}
ver:
description: "The tag with the leading 'v' stripped (e.g. 1.13.1)"
value: ${{ steps.resolve.outputs.ver }}
is_prerelease:
description: "'true' if the GH Release is flagged prerelease"
value: ${{ steps.resolve.outputs.is_prerelease }}
is_draft:
description: "'true' if the GH Release is still a draft"
value: ${{ steps.resolve.outputs.is_draft }}
runs:
using: composite
steps:
- name: Resolve tag and release state
id: resolve
shell: bash
env:
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ inputs.github_token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ -z "$TAG" ]]; then
echo "::error::No tag to resolve (neither workflow_run.head_branch nor the dispatch input was set)"
exit 1
fi
# A missing release here is a real fault, not something to paper over:
# every caller is about to publish this version to an external package
# manager. Fail loudly and let the caller's Report-failure step file it.
if ! META=$(gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease 2>&1); then
echo "::error::No GH Release found for tag $TAG in $REPO -- refusing to bump"
echo "$META"
exit 1
fi
IS_DRAFT=$(echo "$META" | jq -r '.isDraft')
IS_PRERELEASE=$(echo "$META" | jq -r '.isPrerelease')
{
echo "tag=$TAG"
echo "ver=${TAG#v}"
echo "is_draft=$IS_DRAFT"
echo "is_prerelease=$IS_PRERELEASE"
} >> "$GITHUB_OUTPUT"
echo "resolved tag=$TAG ver=${TAG#v} draft=$IS_DRAFT prerelease=$IS_PRERELEASE"
+55 -5
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -69,7 +69,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -110,6 +110,42 @@ jobs:
name: ${{ matrix.desktop-artifact-name }}
path: ${{ matrix.desktop-artifact-path }}
# geode (the standalone Nostr relay) is JVM-only, so it runs in its own job
# rather than the build-desktop matrix — one runner suffices (no reason to test
# a platform-independent module 3× across the desktop OS matrix). Isolating it
# also keeps its default suite's CPU-heavy throughput benchmarks (a 1M-event
# mirror sync, WireReqFloor, NegentropyServerReconcile) from contending with the
# timing-sensitive quartz relay-client tests under org.gradle.parallel — that
# contention flakes tests like NostrClientReqBypassingRelayLimitsTest.
test-geode:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Test geode (gradle)
run: ./gradlew :geode:test
- name: Upload geode Test Reports
uses: actions/upload-artifact@v7
if: failure()
with:
name: geode Test Reports
path: geode/build/reports
test-quartz-ios:
# Phase 1 of the iOS support plan
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
@@ -125,7 +161,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -184,7 +220,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -231,9 +267,23 @@ jobs:
name: Android Lint Reports
path: amethyst/build/reports/lint-results-*.html
# Publishes the JUnit XML produced by the unit-test tasks above as inline
# annotations plus a job summary. Replaces asadmansr/android-test-report-action,
# which was abandoned (last release 2020) and rebuilt an EOL Ubuntu 18.04 +
# Python 2 Docker image on every run — bionic's apt archives have since gone
# unreliable and broke this job. Pinned to a commit SHA (not the movable
# v6.4.2 tag) to close the supply-chain hole. annotate_only avoids needing
# `checks: write`, so it keeps working on pull requests from forks (where the
# GITHUB_TOKEN is read-only). fail_on_failure preserves the old step's
# behavior of marking the job red when a test fails.
- name: Android Test Report
uses: asadmansr/android-test-report-action@v1.2.0
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2
if: always()
with:
report_paths: '**/build/test-results/**/TEST-*.xml'
annotate_only: true
detailed_summary: true
fail_on_failure: true
- name: Upload Test Results
uses: actions/upload-artifact@v7
+43 -31
View File
@@ -19,9 +19,14 @@ name: Bump Homebrew Formula (amy CLI)
# auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the
# "TODO(bootstrap)" note at the bottom of this file.
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the full note in bump-homebrew.yml.
on:
release:
types: [released]
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
@@ -38,44 +43,49 @@ permissions:
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-formula-${{ github.event.release.tag_name || inputs.tag }}
group: bump-homebrew-formula-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
# See bump-homebrew.yml for why these three conditions: successful, tag-push
# (not a dry-run dispatch), v-prefixed. Exact format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.event.release.tag_name || inputs.tag }}"
VER="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.ver.outputs.tag }}"
VER="${{ steps.ver.outputs.ver }}"
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# The `released` event can fire a hair before every matrix leg finishes
# uploading; retry with backoff (mirrors the repo's push/pull retry ethos).
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi
@@ -110,13 +120,13 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-amy-formula-${{ steps.ver.outputs.tag }}
branch: chore/bump-amy-formula-${{ steps.rel.outputs.tag }}
add-paths: cli/packaging/homebrew/amy.rb
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `cli/packaging/homebrew/amy.rb` to the
`${{ steps.ver.outputs.tag }}` release:
`${{ steps.rel.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
@@ -124,19 +134,21 @@ jobs:
Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep
the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add a
# step here that opens the homebrew-core bump PR automatically — symmetric to
# the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump-
# formula, or `brew bump-formula-pr amy --url=<url> --sha256=<sha>` with a
# HOMEBREW_TOKEN). It is intentionally omitted until then because
# bump-formula-pr errors on a formula that is not yet in the tap.
# TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add
# a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs `brew
# bump-formula-pr amy --url=<url> --sha256=<sha>` from a maintainer's
# machine. Do NOT wire that into CI: bump-formula-pr forks homebrew-core
# into the token owner's account, which needs a classic `repo`-scoped PAT,
# and that scope grants write to every repo the account can reach —
# including this one — to anyone with push access here. See
# BUILDING.md § Homebrew cask for the full reasoning.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
@@ -0,0 +1,165 @@
name: Bump Homebrew Formula (geode relay)
# Sibling of bump-homebrew-formula.yml (the amy CLI). Same mechanism, different
# artifact:
# - bump-homebrew-formula.yml -> Formula `amy` (the headless CLI)
# - this workflow -> Formula `geode` (the standalone relay)
#
# After a stable release, download the published `geode-<version>-jvm.tar.gz`
# bundle, compute its sha256, and open a PR that syncs
# `geode/packaging/homebrew/geode.rb`'s url + sha256 to that release. Keeping the
# in-repo reference formula accurate makes the eventual homebrew-core submission a
# copy-paste.
#
# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew
# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core,
# and `geode` has never been submitted there — that first submission is a manual,
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
# auto-bump here — see the "TODO(bootstrap)" note at the bottom of this file.
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the full note in bump-homebrew.yml.
on:
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-geode-formula-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
# See bump-homebrew.yml for why these three conditions: successful, tag-push
# (not a dry-run dispatch), v-prefixed. Exact format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/geode-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o geode-jvm.tar.gz "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s geode-jvm.tar.gz
SHA=$(shasum -a 256 geode-jvm.tar.gz | awk '{print $1}')
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "geode-${VER}-jvm.tar.gz -> $SHA"
- name: Update reference formula
run: |
set -euo pipefail
FORMULA=geode/packaging/homebrew/geode.rb
URL="${{ steps.asset.outputs.url }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Rewrite the two indented lines in the formula block. Anchoring on the
# 2-space indent avoids touching the header comment's example curl url.
sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA"
sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA"
echo "----- $FORMULA -----"
grep -E "^ (url|sha256) " "$FORMULA"
- name: Open or update the formula-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-geode-formula-${{ steps.rel.outputs.tag }}
add-paths: geode/packaging/homebrew/geode.rb
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `geode/packaging/homebrew/geode.rb` to the
`${{ steps.rel.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
Opened by `.github/workflows/bump-homebrew-geode-formula.yml`. Merge to
keep the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core,
# add a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs
# `brew bump-formula-pr geode --url=<url> --sha256=<sha>` from a
# maintainer's machine. Do NOT wire that into CI — it needs a classic
# `repo`-scoped PAT, which as a CI secret would hand write access to this
# repo to anyone with push access. See BUILDING.md § Homebrew cask.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew-geode-formula failed for ${tag}`,
body: [
`geode Homebrew formula sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Formula (\`geode\` relay)`,
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Manually update \`geode/packaging/homebrew/geode.rb\` (url + sha256) from the release asset`,
`3. Check the release actually published \`geode-${tag.replace(/^v/, '')}-jvm.tar.gz\``
].join('\n'),
labels: ['release-ops', 'bug']
});
+138 -32
View File
@@ -1,75 +1,181 @@
name: Bump Homebrew Cask
name: Sync Homebrew Cask Reference
# Fires when a GH Release is published (not draft, not prerelease).
# `release.types: [released]` event fires only for stable releases — still
# double-checked by .github/actions/assert-stable-release for defense-in-depth.
# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml
# (geode). Same mechanism, third artifact:
# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
#
# What it does: after a stable release, download the published macOS DMG, assert
# it is notarized + stapled, compute its sha256, and open a PR syncing
# `desktopApp/packaging/homebrew/amethyst-nostr.rb` to that release.
#
# What it does NOT do: open a PR against Homebrew/homebrew-cask. That step is
# deliberately MANUAL and runs on a maintainer's machine —
# `scripts/bump-homebrew-cask.sh`. Reason: `brew bump-cask-pr` forks
# homebrew-cask into the token owner's account, which requires a CLASSIC PAT
# with the `repo` scope; that scope grants write to every repository the account
# can reach, and stored as a CI secret it would be usable by anyone with push
# access to this repo. Keeping it in a maintainer's shell instead of a CI secret
# removes that blast radius entirely, at the cost of one command per release.
# See BUILDING.md § Homebrew cask.
#
# Consequence: this workflow needs NO external token. GITHUB_TOKEN is enough,
# exactly like the two formula workflows.
#
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the note in bump-homebrew-formula.yml.
on:
release:
types: [released]
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to bump (for manual recovery)'
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: read
# The "Report failure" step below opens a [release-ops] issue via
# github.rest.issues.create; that needs issues:write. Without it the failure
# reporter itself 403s and no alert is ever filed.
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
# Serialize bumps per tag; do not cancel in-progress bumps.
group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }}
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota
timeout-minutes: 30
sync-cask:
# See bump-homebrew-formula.yml for why these three conditions: successful,
# tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
# macOS runner: `xcrun stapler` is the only way to verify the notarization
# ticket, and shipping an unnotarized DMG to the cask is the failure mode
# this whole channel is most exposed to.
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Bump cask (push-or-update PR)
uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
- name: Download DMG, verify notarization, compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-macos-arm64.dmg"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amethyst.dmg "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amethyst.dmg
# A cask must point at a notarized+stapled DMG or every user hits a
# Gatekeeper block. Refuse to advertise one that is not.
if ! xcrun stapler validate amethyst.dmg; then
echo "::error::${TAG} DMG has no stapled notarization ticket -- refusing to sync the cask. Check the notarizeReleaseDmg step in create-release.yml."
exit 1
fi
SHA=$(shasum -a 256 amethyst.dmg | awk '{print $1}')
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "amethyst-desktop-${VER}-macos-arm64.dmg -> $SHA"
- name: Update reference cask
run: |
set -euo pipefail
CASK=desktopApp/packaging/homebrew/amethyst-nostr.rb
VER="${{ steps.rel.outputs.ver }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Anchor on the 2-space indent so the header comment's example lines
# are never touched.
sed -i '' -E "s|^( version ).*|\1\"${VER}\"|" "$CASK"
sed -i '' -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$CASK"
echo "----- $CASK -----"
grep -E "^ (version|sha256) " "$CASK"
- name: Open or update the cask-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.HOMEBREW_TOKEN }}
tap: homebrew/cask
cask: amethyst-nostr
tag: ${{ github.event.release.tag_name || inputs.tag }}
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-amethyst-cask-${{ steps.rel.outputs.tag }}
add-paths: desktopApp/packaging/homebrew/amethyst-nostr.rb
commit-message: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `desktopApp/packaging/homebrew/amethyst-nostr.rb` to the
`${{ steps.rel.outputs.tag }}` release:
- `version` -> `${{ steps.rel.outputs.ver }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
The DMG was verified notarized + stapled before this PR was opened.
**Merge this, then push it upstream from a maintainer machine:**
```bash
scripts/bump-homebrew-cask.sh ${{ steps.rel.outputs.tag }}
```
That step is manual on purpose — it needs a classic PAT with the
`repo` scope, which is deliberately NOT stored as a CI secret. See
BUILDING.md § Homebrew cask.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew failed for ${tag}`,
title: `[release-ops] sync-homebrew-cask failed for ${tag}`,
body: [
`Homebrew cask bump failed for release \`${tag}\`.`,
`amethyst-nostr cask sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Cask (\`amethyst-nostr\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``,
`3. File PR directly against Homebrew/homebrew-cask`
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Check the DMG is notarized: \`xcrun stapler validate\` on the release asset`,
`3. Manually update \`desktopApp/packaging/homebrew/amethyst-nostr.rb\` (version + sha256)`
].join('\n'),
labels: ['release-ops', 'bug']
});
+171 -29
View File
@@ -1,72 +1,214 @@
name: Bump Winget Manifest
name: Sync Winget Manifest Reference
# Fourth sibling of the three Homebrew sync workflows, same shape:
# bump-homebrew-formula.yml -> Formula `amy`
# bump-homebrew-geode-formula.yml -> Formula `geode`
# bump-homebrew.yml -> Cask `amethyst-nostr`
# this workflow -> Winget `VitorPamplona.Amethyst`
#
# What it does: after a stable release, download the published Windows MSI,
# compute its sha256, read its ProductCode, and open a PR syncing
# desktopApp/packaging/winget/*.yaml to that release.
#
# What it does NOT do: open a PR against microsoft/winget-pkgs. That step is
# deliberately MANUAL and runs on a maintainer's machine —
# `scripts/bump-winget.sh`. Reason: submitting requires push access to a fork of
# winget-pkgs. The previous design stored a classic `public_repo` PAT as
# WINGET_TOKEN and handed it to a third-party action; that scope grants write to
# every public repo the account can reach, and as an Actions secret it was
# usable by anyone with push access to this repo. The local script uses the
# maintainer's existing `gh` auth instead, so no PAT is created at all.
# See BUILDING.md § Winget.
#
# Consequence: this workflow needs NO external token and no third-party action.
#
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the note in bump-homebrew-formula.yml.
on:
release:
types: [released]
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to submit (for manual recovery)'
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: read
# The "Report failure" step below opens a [release-ops] issue via
# github.rest.issues.create; that needs issues:write. Without it the failure
# reporter itself 403s and no alert is ever filed.
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
group: bump-winget-${{ github.event.release.tag_name || inputs.tag }}
group: bump-winget-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: windows-latest
timeout-minutes: 30
sync-manifest:
# See bump-homebrew-formula.yml for why these three conditions: successful,
# tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
# Linux, not Windows: msitools reads the MSI Property table just as well, and
# this leg is billed 1x instead of 2x.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Submit manifest to winget-pkgs
uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
- name: Install msitools
run: sudo apt-get update -qq && sudo apt-get install -y -qq msitools
- name: Download MSI, compute sha256 + ProductCode
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-windows-x64.msi"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amethyst.msi "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amethyst.msi
SHA=$(sha256sum amethyst.msi | awk '{print $1}' | tr '[:lower:]' '[:upper:]')
# ProductCode is the ARP key winget uses to detect an existing install.
# jpackage regenerates it per build, so read it rather than pin it.
PRODUCT_CODE=$(msiinfo export amethyst.msi Property \
| awk -F'\t' '$1 == "ProductCode" { print $2 }' | tr -d '\r')
if [[ ! "$PRODUCT_CODE" =~ ^\{[0-9A-Fa-f-]{36}\}$ ]]; then
echo "::error::could not read a valid ProductCode from the MSI (got: '${PRODUCT_CODE}')"
exit 1
fi
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "product_code=$PRODUCT_CODE" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA"
echo "ProductCode=$PRODUCT_CODE"
- name: Update reference manifests
run: |
set -euo pipefail
DIR=desktopApp/packaging/winget
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
SHA="${{ steps.asset.outputs.sha256 }}"
URL="${{ steps.asset.outputs.url }}"
PC="${{ steps.asset.outputs.product_code }}"
# Anchored substitutions so the header comments are never touched.
sed -i -E "s|^(PackageVersion: ).*|\1${VER}|" \
"$DIR/VitorPamplona.Amethyst.yaml" \
"$DIR/VitorPamplona.Amethyst.installer.yaml" \
"$DIR/VitorPamplona.Amethyst.locale.en-US.yaml"
sed -i -E "s|^( InstallerUrl: ).*|\1${URL}|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
# Quoted: an all-digit 64-char digest would otherwise parse as a YAML
# integer and fail the schema's `string` type.
sed -i -E "s|^( InstallerSha256: ).*|\1'${SHA}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
sed -i -E "s|^( ProductCode: ).*|\1'${PC}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
sed -i -E "s|^(ReleaseNotesUrl: ).*|\1https://github.com/${{ github.repository }}/releases/tag/${TAG}|" \
"$DIR/VitorPamplona.Amethyst.locale.en-US.yaml"
echo "----- synced -----"
grep -hE "^(PackageVersion| InstallerUrl| InstallerSha256| ProductCode|ReleaseNotesUrl): " "$DIR"/*.yaml
- name: Sanity-check the manifests still parse
run: |
set -euo pipefail
python3 - <<'PY'
import glob, sys, yaml
for f in sorted(glob.glob('desktopApp/packaging/winget/*.yaml')):
d = yaml.safe_load(open(f))
assert d['PackageIdentifier'] == 'VitorPamplona.Amethyst', f
assert d['PackageVersion'], f
print('OK', f, d['ManifestType'])
PY
- name: Open or update the manifest-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
identifier: VitorPamplona.Amethyst
version: ${{ github.event.release.tag_name || inputs.tag }}
# Asset naming contract: scripts/asset-name.sh
installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$'
token: ${{ secrets.WINGET_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-winget-manifest-${{ steps.rel.outputs.tag }}
add-paths: desktopApp/packaging/winget
commit-message: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `desktopApp/packaging/winget/` to the
`${{ steps.rel.outputs.tag }}` release:
- `PackageVersion` -> `${{ steps.rel.outputs.ver }}`
- `InstallerSha256` -> `${{ steps.asset.outputs.sha256 }}`
- `ProductCode` -> `${{ steps.asset.outputs.product_code }}`
**Merge this, then push it upstream from a maintainer machine:**
```bash
scripts/bump-winget.sh ${{ steps.rel.outputs.tag }}
```
That step is manual on purpose — it needs push access to a fork of
`microsoft/winget-pkgs`, which is deliberately NOT stored as a CI
secret. The script uses your existing `gh` auth. See
BUILDING.md § Winget.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-winget failed for ${tag}`,
title: `[release-ops] sync-winget-manifest failed for ${tag}`,
body: [
`Winget manifest submission failed for release \`${tag}\`.`,
`Winget manifest sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Winget (\`VitorPamplona.Amethyst\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``,
`3. File PR directly against microsoft/winget-pkgs`
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Check the release actually published \`amethyst-desktop-${tag.replace(/^v/, '')}-windows-x64.msi\``,
`3. Manually update \`desktopApp/packaging/winget/*.yaml\` (version, sha256, ProductCode)`
].join('\n'),
labels: ['release-ops', 'bug']
});
+343 -8
View File
@@ -53,7 +53,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -155,8 +155,44 @@ jobs:
AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
with:
max_attempts: 2
timeout_minutes: 15
command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}
# macOS needs far longer: notarizeReleaseDmg blocks on `notarytool
# submit --wait`, which is minutes-to-tens-of-minutes on Apple's side.
timeout_minutes: ${{ matrix.family == 'macos' && 45 || 15 }}
# Append notarization on the macOS leg. `packageReleaseDmg` only SIGNS
# the DMG — notarization is a separate Compose task, and because it was
# never invoked every release up to v1.13.1 shipped a signed but
# UNNOTARIZED DMG that Gatekeeper blocks on first launch. The task runs
# `notarytool submit --wait` and then `stapler staple`, in place, so the
# asset-collection step below still finds the same file.
#
# Gated on the cert AND all three notary secrets being present, so forks
# and credential-less runs keep producing a plain unsigned DMG exactly as
# before instead of failing.
command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}${{ (matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && ' :desktopApp:notarizeReleaseDmg' || '' }}
# Regression guard. The missing-notarization bug was invisible for many
# releases precisely because nothing ever asserted the outcome; assert it
# now so a silently-dropped notarization step can never ship again.
- name: Verify the DMG is notarized and stapled (macOS leg)
if: matrix.family == 'macos'
env:
EXPECT_NOTARIZED: ${{ (steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && 'true' || 'false' }}
run: |
set -euo pipefail
DMG=$(find desktopApp/build/compose/binaries -name "*.dmg" -print -quit)
[[ -n "$DMG" ]] || { echo "::error::no DMG produced"; exit 1; }
echo "Checking $DMG"
if [[ "$EXPECT_NOTARIZED" != "true" ]]; then
echo "::warning::Apple signing/notary credentials are not configured; this DMG is unsigned and unnotarized. Gatekeeper will block it, and it is not eligible for the Homebrew cask."
exit 0
fi
if ! xcrun stapler validate "$DMG"; then
echo "::error::$DMG has no stapled notarization ticket -- notarizeReleaseDmg did not run or failed"
exit 1
fi
echo "notarization ticket stapled OK"
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
@@ -249,7 +285,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -301,7 +337,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -500,7 +536,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -518,6 +554,305 @@ jobs:
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# geode relay build matrix. Same shape as build-cli — geode is the same kind
# of `application`-plugin JVM module — producing a self-contained bundle with a
# minimal jlink'd JRE (no system Java required) plus native Linux packages.
#
# geodeImage task (all legs): geode/build/geode-image/geode/ → geode-*.tar.gz
# jpackageDeb / jpackageRpm: geode/build/jpackage/geode_*.deb + geode-*.rpm
# no-JRE jvm bundle (linux): geode-<ver>-jvm.tar.gz for the Homebrew formula
#
# Unlike build-cli there is no "no Compose UI" assertion — geode depends only on
# :quartz and never pulls the Compose render stack. The GHCR Docker image is a
# separate job (docker-geode) below.
#
# Asset naming: geode-<version>-<family>-<arch>.<ext>. See scripts/asset-name.sh.
# ---------------------------------------------------------------------------
build-geode:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
- name: Resolve tag + version
id: ver
env:
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
TEST_TAG: ${{ github.event.inputs.test_tag || '' }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
TAG="${TEST_TAG:-v0.0.0-dryrun}"
else
TAG="${GITHUB_REF_NAME}"
fi
VER="${TAG#v}"
TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then
if [[ "$TOML_VER" != "$VER" ]]; then
echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG"
exit 1
fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Install RPM tooling (linux only)
if: matrix.family == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Build geode artifacts
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 15
command: ./gradlew --no-daemon :geode:${{ matrix.tasks }}
# Boot the bundled jlink image before shipping it. `--version` proves the
# JVM starts and the main class loads; the serve leg proves the jlink
# module list is complete for the real relay path (Ktor CIO + SQLite +
# NIP-11 serialization) — a too-tight module list links fine but fails
# here with NoClassDefFound instead of on an operator's machine.
- name: Smoke-test the geode image
run: |
set -euo pipefail
IMG="geode/build/geode-image/geode"
"$IMG/bin/geode" --version
"$IMG/bin/geode" --port 17447 &
PID=$!
ok=0
for i in $(seq 1 20); do
if curl -fsS -H 'Accept: application/nostr+json' http://127.0.0.1:17447/ -o /tmp/nip11.json; then ok=1; break; fi
sleep 1
done
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
[[ "$ok" == 1 ]] || { echo "::error::geode image did not serve NIP-11 within 20s"; exit 1; }
echo "NIP-11 doc:"; head -c 400 /tmp/nip11.json; echo
grep -q '"supported_nips"' /tmp/nip11.json || { echo "::error::NIP-11 doc missing supported_nips"; exit 1; }
# macOS only: import the Developer ID cert (no-op without the secret) so
# the next step can codesign the jlink image. The jvm bundle for
# Homebrew-core is NOT signed here — Homebrew strips quarantine itself.
- name: Import Apple Developer ID certificate (macOS leg, if configured)
if: matrix.family == 'macos'
id: mac_keychain
uses: ./.github/actions/import-macos-cert
with:
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
# Codesign + notarize the macOS jlink image (geode-<ver>-macos-arm64.tar.gz)
# for operators who download it directly. A loose tarball can't be stapled
# (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization
# online on first run. Runs before "Collect" so the tarred image is signed.
- name: Sign + notarize geode image (macOS leg, if configured)
if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true'
env:
SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }}
NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
run: |
set -euo pipefail
IMG="geode/build/geode-image/geode"
ENTITLEMENTS="geode/packaging/macos/geode.entitlements"
# Sign the macOS Mach-O natives buried INSIDE the bundled jars
# (secp256k1/sqlite) first — Apple's notary recurses into jars and
# rejects any unsigned Mach-O, so this must run before notarize.
SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG"
# Sign every loose Mach-O binary in the bundled JRE. Executables get
# the hardened-runtime entitlements; dylibs don't.
while IFS= read -r f; do
case "$(file -b "$f")" in
*Mach-O*executable*)
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;;
*Mach-O*)
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$f" ;;
esac
done < <(find "$IMG" -type f)
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
# Notarize: zip the signed image, submit, wait for Apple's verdict.
ZIP="$RUNNER_TEMP/geode-notarize.zip"
OUT="$RUNNER_TEMP/notary-submit.json"
ditto -c -k --keepParent "$IMG" "$ZIP"
if ! xcrun notarytool submit "$ZIP" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then
echo "::warning::notarytool submit exited non-zero"
fi
cat "$OUT"
STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)"
SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)"
if [ "$STATUS" != "Accepted" ]; then
echo "::error::Notarization status: $STATUS"
if [ -n "$SUBMISSION_ID" ]; then
echo "----- notary log -----"
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" || true
fi
exit 1
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
- name: Relax libicu dependency in .deb
if: matrix.family == 'linux'
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh geode/build/jpackage/*.deb
- name: Collect + rename assets
run: |
set -euo pipefail
# shellcheck source=scripts/asset-name.sh
source scripts/asset-name.sh
collect_geode_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
# Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it
# cannot use the jlink tarball above (bundled runtime) nor build from
# source (its sandbox blocks Gradle's Maven downloads). installDist
# (bin/geode + lib/*.jar, no runtime/) is exactly that bundle. It is pure
# JVM bytecode, so one platform-independent asset serves every OS; we cut
# it on the linux leg only. geodeImage depends on installDist, so the
# geode/build/install/geode tree already exists here.
- name: Package no-JRE jvm bundle for Homebrew (linux leg only)
if: matrix.family == 'linux'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
SRC="geode/build/install/geode"
test -x "$SRC/bin/geode"
( cd "$SRC" && tar czf "$OLDPWD/dist/geode-${VER}-jvm.tar.gz" bin lib )
echo "Collected: dist/geode-${VER}-jvm.tar.gz"
- name: Enforce geode size budget (200 MB per asset)
run: |
set -euo pipefail
fail=0
for f in dist/*; do
if [[ -f "$f" ]]; then
size=$(wc -c < "$f")
mb=$(( size / 1048576 ))
if (( size > 209715200 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB geode budget"
fail=1
else
echo "OK: $f — ${mb} MB"
fi
fi
done
[[ "$fail" == 0 ]]
- name: Classify release
id: classify
run: |
TAG="${{ steps.ver.outputs.tag }}"
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
draft: false
fail_on_unmatched_files: true
generate_release_notes: false # Android job writes release notes (last-writer-wins race)
- name: Dry-run summary
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: |
echo "### Dry-run: geode ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# geode Docker image → GitHub Container Registry (GHCR). A relay is most often
# deployed as a container, so this is geode's primary distribution channel.
# Builds geode/Dockerfile (multi-stage: gradle installDist → temurin JRE) and
# pushes ghcr.io/<owner>/geode:<version> (+ :latest on a stable release).
# Tag-push only — skipped on the workflow_dispatch dry-run.
# ---------------------------------------------------------------------------
docker-geode:
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve version + tags
id: meta
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VER="${TAG#v}"
# Lowercase owner — GHCR repository paths must be lowercase.
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
IMAGE="ghcr.io/${OWNER}/geode"
TAGS="${IMAGE}:${VER}"
# Only move :latest for a stable vX.Y.Z tag, never a prerelease.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TAGS="${TAGS},${IMAGE}:latest"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v7
with:
context: .
file: geode/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ---------------------------------------------------------------------------
# Android build + sign + direct-upload. Logic preserved from previous workflow;
# uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset.
@@ -531,7 +866,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -675,7 +1010,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ github.ref_name }}
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -58,7 +58,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
+1 -1
View File
@@ -8,6 +8,6 @@
</component>
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.0" />
<option name="version" value="2.4.10" />
</component>
</project>
+109 -20
View File
@@ -325,15 +325,29 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~2530 min).
4. **Verify**:
- GH Release contains 8 desktop assets + 12 Android assets
4. **Verify** — the GH Release should hold **31 assets**:
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
configured, so macOS ships arm64-only.
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent.
- **5 amy** + **5 geode** bundles.
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
- Intel + ARM DMGs both present
- Android flow unchanged
Quick diff against the previous release, which catches a silently-dropped
matrix leg better than any count:
```bash
diff <(gh release view v1.13.0 --json assets --jq '.assets[].name' | sed 's/1\.13\.0/VER/g' | sort) \
<(gh release view v1.13.1 --json assets --jq '.assets[].name' | sed 's/1\.13\.1/VER/g' | sort)
```
5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
Homebrew + Winget bump workflows.
or `-snapshot` is auto-classified as prerelease. Only stable tags run the
Homebrew + Winget bump workflows (and those are no-ops until the one-time
bootstrap PRs land — see § Bootstrap).
### Dry-run (no tag push)
@@ -389,8 +403,8 @@ provided automatically; everything else you set yourself.)
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
| ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — |
| ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
Note the **three distinct signing identities** people often conflate:
@@ -473,11 +487,11 @@ distributes; the official Amethyst rollout for each is in
| Channel | How it ships | Push or pull |
|---|---|---|
| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | Automatic (CI) |
| **Google Play** | Download the signed `amethyst-googleplay-<version>.aab` from the GH Release and upload it in Play Console | **Manual push** |
| **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** |
| **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags — **currently no-ops**: neither package has been bootstrapped upstream yet (§ Bootstrap) | Automatic (CI), inactive |
Two channels need the build to stay split into product flavors (see
`amethyst/build.gradle.kts` → `productFlavors`):
@@ -498,20 +512,88 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
### Secrets to provision in GitHub repo settings
> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.**
> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so
> **Amethyst does not currently ship through either channel.** The bump
> workflows detect this and skip with a `::warning::` instead of failing, so a
> green release run does *not* mean Homebrew/Winget shipped. The two subsections
> below are the work that activates them; until then treat the desktop app as
> GitHub-Releases-only on macOS and Windows.
### Package-manager credentials (and why there are none)
The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs).
The two that need the most setup care are the package-manager PATs, because of
their token type and scope:
Neither package-manager channel adds anything to it:
| Secret | Purpose | Scope |
|---|---|---|
| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry |
| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) |
**There are deliberately no package-manager PATs in CI.** Both the Homebrew
cask and the Winget manifest bumps run on a maintainer's machine. The reasoning
is worth keeping, because it is the reason this repo has no third secret to
rotate:
Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md`
or equivalent issue tracker. On rotation, paste new token and run
`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify.
`brew bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's
account** (`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that
fork, then opens the PR upstream. That shape forces a **classic** PAT with the
`repo` scope:
- A fine-grained PAT cannot express it. Its "Repository access" selector only
lists repos owned by the resource owner, so `Homebrew/homebrew-cask` can never
be selected — and Homebrew's API layer authorises against classic OAuth scopes
(`x-oauth-scopes`), which fine-grained tokens do not emit.
- Homebrew declares the requirement in source as
`CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`).
And `repo` cannot be narrowed: it grants write to *every* repository the owning
account can reach — including `vitorpamplona/amethyst` itself. Stored as an
Actions secret it would be usable by **anyone with push access to this repo**,
since a pushed branch containing a workflow runs with repo secrets. That is a
strict escalation for a channel that ships one DMG a month.
So the split is:
- **CI** (`bump-homebrew.yml`, `GITHUB_TOKEN` only) does the error-prone
bookkeeping: downloads the DMG, asserts it is notarized + stapled, computes
the sha256, and opens an in-repo PR syncing
`desktopApp/packaging/homebrew/amethyst-nostr.rb`.
- **A maintainer** merges that PR and runs `scripts/bump-homebrew-cask.sh`,
which re-verifies the sha256 and the notarization ticket against the live
asset before calling `brew bump-cask-pr`.
The token then lives only in that maintainer's shell:
```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
```
Create one at
<https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump>.
Prefer a dedicated bot account whose only asset is a fork of `homebrew-cask`, so
a leak reaches nothing else.
### Winget
Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
`gh`, which a maintainer is already authenticated with, and it does not need
`wingetcreate` (Windows-only) because winget manifests are plain YAML — so it
runs fine from macOS or Linux:
```bash
scripts/bump-winget.sh v1.13.2
```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
MSI, computes the sha256, reads the `ProductCode` out of the MSI Property table
with `msitools`, and opens an in-repo PR syncing
`desktopApp/packaging/winget/*.yaml`. The script re-verifies the sha256 against
the live asset, then forks `microsoft/winget-pkgs`, commits the three manifests
to `manifests/v/VitorPamplona/Amethyst/<version>/`, and opens the PR.
The previous design stored a classic `public_repo` PAT as `WINGET_TOKEN` and
passed it to the third-party `vedantmgoyal9/winget-releaser` action — a token
with write access to every public repo the account owns, handed to code we do
not control, in a place any push-access collaborator could read it from. None of
that is needed.
### Homebrew cask (one-time initial PR)
@@ -636,12 +718,19 @@ by any other channel.
| OS | App location | State directories |
|---|---|---|
| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
| macOS | `/Applications/Amethyst.app` | `~/.amethyst` (accounts + keys)<br>`~/Library/Application Support/Amethyst` (Tor)<br>`~/Library/Caches/AmethystDesktop` (image cache)<br>`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) |
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.cache/amethyst` |
| Linux (AppImage/tar.gz) | user-chosen | Same as above |
| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` |
**macOS preferences are in a SHARED file.** `DesktopPreferences` uses the Java
Preferences API, which on macOS writes into
`~/Library/Preferences/com.apple.java.util.prefs.plist` — one plist for *every*
Java application on the machine, not a per-app file. Never delete it to "reset
Amethyst": that wipes unrelated apps' settings. This is why the Homebrew cask's
`zap` stanza deliberately omits it.
Uninstall:
- Homebrew: `brew uninstall --cask amethyst-nostr && brew zap amethyst-nostr`
+6 -6
View File
@@ -17,7 +17,7 @@ Join the social network you control.
[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![License: MIT](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst)
## Download and Install
@@ -328,16 +328,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.12.6')
implementation('com.vitorpamplona.quartz:quartz:1.13.1')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-android:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.1')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+94 -23
View File
@@ -14,7 +14,7 @@ specific to shipping the official Amethyst artifacts.
## At a glance
A release is one tag push that fans out to five distribution channels:
A release is one tag push that fans out to four live distribution channels:
| Channel | Mechanism | Who pushes |
|---|---|---|
@@ -22,10 +22,11 @@ A release is one tag push that fans out to five distribution channels:
| **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer |
| **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) |
| **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer |
| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI |
| **Homebrew + Winget** | ⚠️ **Not shipping.** The bump workflows run, but skip: neither package exists upstream yet | Nobody (see § 3) |
Maven Central (the `quartz` library) also publishes automatically from the same
workflow.
workflow — as a *step* at the end of the `deploy-android` job, not a job of its
own, so don't expect to find it in the run's job list.
---
@@ -61,8 +62,10 @@ workflow.
`scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` /
`CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.)
3. **Publish the release-notes note on Nostr** with Amethyst's account and paste
its event id into `amethyst/build.gradle.kts`:
3. **Publish the release-notes note on Nostr** — *minor releases only.* In
practice this id has only ever been bumped on `x.y.0` (1.11.0, 1.12.0,
1.13.0); patch releases leave it pointing at their minor's note. Publish with
Amethyst's account and paste the event id into `amethyst/build.gradle.kts`:
```kotlin
buildConfigField("String", "RELEASE_NOTES_ID", "\"<new-event-id-hex>\"")
```
@@ -85,17 +88,33 @@ workflow.
Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook)
for the exact commands. The tag must equal `app` from the catalog (the workflow
asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is
classified **stable** and triggers the Homebrew/Winget bumps; anything with a
`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
classified **stable** and runs the Homebrew/Winget bump workflows; anything with
a `-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
Heads-up on the `git push`: this repo has `git-credential-manager` configured as
a credential helper, and it blocks on an interactive prompt (a plain
`GIT_TERMINAL_PROMPT=0` does **not** stop it — the push just hangs). If that
happens, push using `gh`'s helper for the one command:
```bash
git -c credential.helper= -c credential.helper='!gh auth git-credential' push upstream main
```
When the `Create Release Assets` workflow finishes (~2530 min) the GH Release
holds, per the asset-name contract:
holds **31 assets**, per the asset-name contract:
- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`)
- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz)
- **CLI:** the `amy` artifacts
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published
- **Android (13):** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid
`.apks` set for Accrescent
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab` / `.apks`)
- **Desktop (8):** DMG (macOS **arm64 only** — there is no Intel DMG),
MSI + zip, DEB, RPM, AppImage, flatpak, tar.gz
- **CLI (5):** the `amy` artifacts
- **Relay (5):** the `geode` artifacts, plus the geode Docker image
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published.
`repo1.maven.org` lags the publish by tens of minutes — a 404 right after the
run is normal. Confirm the step's log says "Deployment is being published to
Maven Central", and compare against the *previous* version's POM before
concluding anything is broken.
---
@@ -165,11 +184,53 @@ RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vi
Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app
itself reads from.
### Homebrew + Winget — automatic
`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs
against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and
`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails —
then see BUILDING.md § Bootstrap and § Incident response.
### Homebrew + Winget — ⚠️ not shipping yet
`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against
`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs`
(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted
upstream**, so both workflows detect that and skip with a `::warning::`. As of
**v1.13.1** these two channels deliver nothing; macOS and Windows users get the
desktop app from GitHub Releases only.
Two separate faults kept this invisible until v1.13.1, both now fixed:
1. **The workflows never ran at all** — for *any* release. They triggered on
`release: types: [released]`, and GitHub does not raise workflow-triggering
events for a release created by `GITHUB_TOKEN`, which is exactly how
`create-release.yml` creates it. They now trigger on `workflow_run` after
`Create Release Assets` succeeds, which also fixes a latent race — the old
event fired while assets were still uploading.
2. **Nothing exists upstream to bump.** `brew bump-cask-pr` and
`winget-releaser` can only *update* an existing package. The first
submission is a manual, human-reviewed PR: BUILDING.md § Homebrew cask
(one-time initial PR) and § Winget (one-time initial submission).
Until someone does that bootstrap, a green release run means the bump workflows
*skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings
if you want to confirm which case you're in.
**Both bumps are half-manual by design.** CI does the bookkeeping with
`GITHUB_TOKEN` only — verifying the artifact, computing hashes, and opening an
in-repo PR syncing the reference packaging files. Pushing upstream needs
credentials that would be dangerous as CI secrets (a `repo`-scoped PAT is
readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
the Homebrew one additionally refuses if the DMG is not notarized + stapled.
See BUILDING.md § Package-manager credentials.
All four in-repo sync workflows (`amy` formula, `geode` formula,
`amethyst-nostr` cask, winget manifests) open PRs against *this* repo on every
release. Merge them to keep the reference packaging files current.
---
@@ -211,7 +272,7 @@ ownership:
| `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) |
| `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise |
| `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry |
| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) |
| *(none for Homebrew/Winget)* | — | Both bumps run on a maintainer's machine — `scripts/bump-homebrew-cask.sh` and `scripts/bump-winget.sh` — so neither channel's PAT ever becomes a CI secret. See BUILDING.md § Package-manager credentials |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise |
Owner assignments and rotation reminders live with the team (issue tracker).
@@ -222,13 +283,23 @@ Owner assignments and rotation reminders live with the team (issue tracker).
## 6. Post-release verification
- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane.
- [ ] Maven Central: `quartz:<version>` resolves (allow propagation time).
- [ ] GH Release: 31 assets, sizes sane, and the asset-name set matches the
previous release (see the `diff` one-liner in BUILDING.md § Release
runbook). macOS is arm64-only — do **not** look for an Intel DMG.
- [ ] Maven Central: `quartz:<version>` resolves (allow tens of minutes of
propagation; the publish step's log is the authoritative signal).
- [ ] Play Console: rollout started, no policy rejection.
- [ ] Zapstore: release event visible.
- [ ] F-Droid: new version detected (may lag days).
- [ ] Homebrew + Winget bump PRs opened (stable only).
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`.
- [ ] Four sync PRs opened against this repo (`amy` formula, `geode` formula,
`amethyst-nostr` cask, winget manifests) — merge them.
- [ ] Cask pushed upstream: `scripts/bump-homebrew-cask.sh vX.Y.Z` (manual, needs
`HOMEBREW_GITHUB_API_TOKEN` in your shell).
- [ ] Winget pushed upstream: `scripts/bump-winget.sh vX.Y.Z` (manual, no token —
uses your `gh` auth).
Both scripts error clearly until the one-time bootstrap PRs land (§ 3).
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`
(only bumped on minor releases — patches keep pointing at the x.y.0 note).
- [ ] Push still works on a `play` build (only if the push contract changed —
see § 4); UnifiedPush still works on an `fdroid` build.
+43 -43
View File
@@ -24,7 +24,9 @@ Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, a
2. **Android SDK**
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
- Required components: build-tools, platform-tools, platforms;android-35
- Required components: build-tools, platform-tools, platforms;android-37
- The exact SDK level is `android-compileSdk` in `gradle/libs.versions.toml` —
check there if this number has drifted.
3. **Git** for cloning the repository
@@ -66,48 +68,54 @@ keyPassword=your-password
### 3. Configure Signing
Add to `amethyst/build.gradle` inside the `android {}` block:
Add to `amethyst/build.gradle.kts` inside the `android {}` block:
```gradle
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
```kotlin
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
signingConfigs {
release {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
```
This needs `import java.util.Properties` at the top of the file.
Update the release buildType to use the signing config:
```gradle
```kotlin
buildTypes {
release {
signingConfig signingConfigs.release
getByName("release") {
signingConfig = signingConfigs.getByName("release")
// ... existing config
}
}
```
Verify with `./gradlew :amethyst:signingReport` — the release variants should
report your keystore rather than `~/.android/debug.keystore`.
### 4. Disable Google Services (Required for F-Droid)
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
Edit `amethyst/build.gradle`, comment out the plugin:
```gradle
Edit `amethyst/build.gradle.kts`, comment out the plugin:
```kotlin
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
}
```
@@ -141,8 +149,8 @@ Edit `amethyst/src/main/res/values/strings.xml`:
### Change Package ID
Edit `amethyst/build.gradle`:
```gradle
Edit `amethyst/build.gradle.kts`:
```kotlin
android {
defaultConfig {
applicationId = "com.yourcompany.yourapp"
@@ -152,8 +160,8 @@ android {
### Change Project Name
Edit `settings.gradle`:
```gradle
Edit `settings.gradle.kts`:
```kotlin
rootProject.name = "YourAppName"
```
@@ -167,36 +175,28 @@ Replace icon files in:
Make your app identify itself on posts with `["client", "YourAppName"]`.
**1. Create tag builder extension:**
You do **not** need to add the tag per event type. The client tag is applied
centrally by `NostrSignerWithClientTag`, a signer decorator that appends the tag
to everything it signs (and respects the user's "add client tag" privacy
setting). Changing the name is a one-constant edit:
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
Edit `amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt`:
```kotlin
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
const val CLIENT_TAG_NAME = "YourAppName"
```
**2. Add to TextNoteEvent:**
That constant is passed to `NostrSignerWithClientTag` when the account's signer
is built, so every signed event carries your name.
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
Add import:
```kotlin
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
```
In both `build()` functions, add after `alt(...)`:
```kotlin
client("YourAppName")
```
The tag itself lives in
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/`
(`ClientTag`, `TagArrayBuilderExt`, `NostrSignerWithClientTag`) — you only need to
touch it if you want the optional NIP-89 handler address / relay hint variants.
### Modify Default Relays
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
Edit `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt`
(see also `AmethystDefaults.kt` and `DefaultDmIndexerRelays.kt` in the same folder).
## Troubleshooting
+1 -1
View File
@@ -84,7 +84,7 @@ android {
.get()
.toInt()
versionName = generateVersionName(libs.versions.app.get(), rootDir)
buildConfigField("String", "RELEASE_NOTES_ID", "\"40e817712e397c07ba31784a92fa474aa095896a828c0e2dea0d09c60d49ee1e\"")
buildConfigField("String", "RELEASE_NOTES_ID", "\"f54843af6397f78e39fa75dbe3b7f7de14eb18c4f9c56e60e7825a2c6715719b\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -0,0 +1,380 @@
# Push Notification Redesign — beautiful, modern, per-kind tray notifications
**Date:** 2026-07-23
**Goal:** Replace the flat, mostly-`BigTextStyle`, single-accent tray
notifications with a per-kind design system: distinct accent colors, status-bar
icons, notification *styles* (MessagingStyle / BigPictureStyle / colorized zap
cards), smart aggregation, and Conversation-section integration — so every kind
of Nostr notification (zap, reaction, reply, mention, DM, repost, nutzap, media,
git, badge, chess…) reads at a glance and looks native to modern Android
(1215).
This is a **rendering / UX** plan. The delivery/transport layers (FCM,
UnifiedPush, the always-on relay service, gift-wrap unwrap, account matching,
mute/age/self gates) are untouched — everything below happens after
`EventNotificationConsumer.dispatchForAccount()` decides an event is notifiable.
---
## 1. Current state (what we render today)
**Pipeline (unchanged by this plan):**
`PushNotificationReceiverService` (FCM) / `PushMessageReceiver` (UnifiedPush) /
`NotificationRelayService` (always-on) → `PushWrapDecryptor.unwrapAndFeed`
`LocalCache``NotificationDispatcher` (kind + age + `tagsAnEventByUser` gate) →
`EventNotificationConsumer.dispatchForAccount()` → per-kind `notify*()`
`NotificationUtils` (`NotificationCompat.Builder`).
**What the tray looks like now:**
| Channel | Importance | Style used | Accent | Small icon | Actions |
| --- | --- | --- | --- | --- | --- |
| DMs (`PrivateMessagesID`) | HIGH | `MessagingStyle` ✅ | none | amethyst logo | Reply, Mark-read |
| Zaps (`ZapsID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
| Reactions (`ReactionsID`) | DEFAULT | `BigTextStyle` (+emoji avatar badge) | none | amethyst logo | — |
| Replies (`RepliesID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | Reply (inline) |
| Mentions (`MentionsID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
| Chess (`ChessID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
**Problems this plan fixes:**
1. **No color identity.** Every notification uses the same monochrome amethyst
status-bar icon and no `setColor()`. A zap, a like, and a git PR are visually
indistinguishable in the shade until you read the text.
2. **Only DMs get a real style.** Replies, mentions, public-chat, group chat all
fall back to `BigTextStyle` even though they are conversations that
`MessagingStyle` renders far better (avatar + name + threaded messages, and
it's what Android promotes into the Conversations section).
3. **Media notifications are text-only.** A `PictureEvent` / `VideoEvent`
mention says "X mentioned you" with no preview. `BigPictureStyle` should show
the actual image.
4. **A dozen kinds masquerade as "mentions."** Picture, video (×4), poll, git
(×4), highlight, long-form, wiki all route to the identical
`notifyMention()` → "X mentioned you". They deserve distinct titles/icons.
5. **No aggregation.** The in-app feed rolls up "5 people reacted / zapped your
note" into one `MultiSetCard`; the tray posts N separate notifications. This
is the biggest source of notification spam.
6. **Kind gaps vs. the in-app feed.** The push `when(event)` in
`dispatchForAccount` does **not** route `NutzapEvent` (9321), `OnchainZapEvent`
(8333), `RepostEvent`/`GenericRepostEvent` (6/16), `BadgeAwardEvent` (8),
`LiveActivitiesChatMessageEvent` (1311), or NIP-C7 `ChatEvent` (9) — all of
which *do* appear in the in-app Notifications tab. (`NotificationDispatcher`'s
`NOTIFICATION_KINDS` also has to grow for these to arrive at all.)
7. **Zaps look plain.** A zap — the app's signature interaction — renders as
grey `BigTextStyle` text. It should be a colorized gold card with the amount
as the hero.
---
## 2. Design principles ("beautiful & modern" made concrete)
Modern Android notification design (Material 3, API 2635) gives us six levers.
The redesign uses all six, per kind:
- **P1 — Accent color (`setColor`) + status-bar icon per category.** Each
category gets a monochrome small icon and a brand-consistent accent so the
shade is scannable pre-read. Palette anchored on existing tokens
(`Color.kt`): zaps `BitcoinOrange 0xFFF7931A`, reactions a heart-red, replies
& mentions amethyst `Purple200/500`, DMs a messaging blue-green, git a slate,
badges a gold. Reserve `setColorized(true)` (full-surface color) for the two
highest-signal kinds only — **zaps** and **incoming calls** (calls already
effectively do this via `CallStyle`).
- **P2 — Right style per kind.** `MessagingStyle` for anything conversational
(DMs, replies, mentions-that-are-replies, public/group chat); `BigPictureStyle`
for media; a colorized `BigTextStyle` "amount card" for zaps/nutzaps;
`InboxStyle` for aggregated roll-ups; plain for the rest.
- **P3 — Aggregation that mirrors the in-app `MultiSetCard`.** Reactions,
reposts, and zaps on the *same target note* collapse into one updating
notification: "🤙 Alice, Bob & 3 others liked your post." Reuse the exact
bucketing rules from `CardFeedContentState.convertToCard` (per-target,
per-day, chunk-of-30) so tray and feed agree.
- **P4 — Conversations & People (API 30+).** Publish a dynamic
`ShortcutInfoCompat` per chat partner / thread, tag DM & reply notifications
with `setShortcutId` + `addPerson`, and attach `BubbleMetadata`. This moves
chats into the dedicated Conversations section, enables bubbles, and gives
long-press "priority/silence" controls — the single most "modern" upgrade.
- **P5 — Circular avatars + emoji/kind badges.** Circle-crop the large-icon
avatar (today it's the raw square bitmap); keep the existing NIP-30
custom-emoji corner-badge overlay and extend the same overlay helper to stamp
a small kind glyph (bolt/heart/reply) so the avatar itself signals the kind.
- **P6 — Richer, semantic actions.** Reply (have it), **Mark-read** (have it for
DMs — extend to all), plus per-kind: **Like back** / **Zap back** on
reaction & zap notifications, **View thread** / **Mute thread** on replies &
mentions. All via `SEMANTIC_ACTION_*` so Wear/Auto/Assistant render them
correctly.
---
## 3. Proposed architecture — a per-kind renderer registry
Today the logic is split between a large `when(event)` in
`EventNotificationConsumer` and six near-duplicate `send*Notification` builders
in `NotificationUtils`. We refactor to a **spec + one builder** shape (the same
idea the desktop path already uses via `notifKindFor` + `buildSpec` in
`DesktopNotificationAutoDispatcher`).
**New: `NotificationCategory` (enum) —** the visual identity, replacing the six
ad-hoc channels. Each carries: channel id/name/importance, accent color,
small-icon drawable, default style, and default group key.
```
enum class NotificationCategory {
DIRECT_MESSAGE, // DMs, group chat — HIGH, blue-green, MessagingStyle, Conversation
REPLY, // replies to me — DEFAULT, purple, MessagingStyle, per-thread group
MENTION, // text mention/quote — DEFAULT, purple, plain/MessagingStyle
ZAP, // ln + nutzap + onchain — DEFAULT, gold, colorized amount card, aggregated
REACTION, // likes/emoji — LOW, heart-red, aggregated InboxStyle
REPOST, // boosts — LOW, green, aggregated
MEDIA, // picture/video mention — DEFAULT, purple, BigPictureStyle
ARTICLE, // long-form/wiki/highlight mention — DEFAULT, purple, plain
CODE, // git issue/patch/PR — DEFAULT, slate, plain
BADGE, // NIP-58 award — DEFAULT, gold, BigPictureStyle (badge image)
CHESS, // game events — DEFAULT, brown, plain
// calls keep their own CallNotifier / CallStyle path (already modern)
}
```
**New: `NotificationSpec` (data class) —** the fully-resolved, ready-to-render
description of one notification, produced per event:
```
data class NotificationSpec(
val category: NotificationCategory,
val dedupeId: String, // event id (existing id.hashCode() keying)
val title: String,
val body: String,
val timestamp: Long,
val author: PersonRef?, // name + avatar url → Person / large icon
val bigPictureUrl: String?, // media / badge image
val badgeUrl: String?, // NIP-30 emoji / kind glyph overlay
val deepLinkUri: String, // existing nostr:...?account=...&scrollTo=...
val groupKey: String, // aggregation bucket (per-thread / per-target)
val actions: List<NotifAction>,// Reply / ZapBack / Mute / MarkRead …
val aggregatable: Boolean, // reactions/zaps/reposts → roll-up
)
```
**New: `NotificationSpecFactory` —** one `fun specFor(event, account, ctx):
NotificationSpec?` that owns all the per-kind text/style decisions currently
scattered across `notify()/notifyReply()/notifyMention()/notifyZap()`. Returns
`null` when the event shouldn't notify. This is the single place a future kind
gets added.
**Rewritten: `NotificationUtils.render(spec)` —** one builder that reads the
spec, selects the style, applies accent/icon/colorized, attaches actions and
Person/shortcut, and handles aggregation + group summaries. The six
`send*Notification` functions collapse into this.
**Kept as-is:** `EventNotificationConsumer.dispatchForAccount` remains the gate
(mute/age/self/known-room checks) but its tail becomes
`NotificationUtils.render(NotificationSpecFactory.specFor(event, account, ctx))`.
This keeps the *policy* (who gets notified) exactly where it is and isolates the
*presentation* (how it looks) into a testable, table-driven unit.
---
## 4. Per-kind visual specification (the heart of the redesign)
| Kind(s) | Category | Style | Accent / icon | Title → Body | Actions |
| --- | --- | --- | --- | --- | --- |
| NIP-17 `ChatMessageEvent` (14), file (15), NIP-04 `PrivateDmEvent` (4), Marmot group | DIRECT_MESSAGE | `MessagingStyle` + Conversation shortcut + Bubble | blue-green / `chat` | sender → message | Reply, Mark-read |
| `TextNoteEvent`(1)/`CommentEvent`(1111)/`ChannelMessageEvent`(42) **reply to me** | REPLY | `MessagingStyle` (parent as prior message) | purple / `reply` | "X replied" → excerpt (+ quoted parent) | Reply, View thread, Mute thread |
| Same kinds, **mention/quote only** | MENTION | plain (BigText) | purple / `alternate_email` | "X mentioned you" → excerpt | View, Mute thread |
| `LnZapEvent`(9735) | ZAP | **colorized** amount card (BigText) | gold / `bolt` | "⚡ 2,100 sats from X" → zap comment + zapped-note excerpt | Zap back, View |
| `NutzapEvent`(9321) *(new)* | ZAP | colorized amount card | gold / `bolt` (cashu tint) | "🥜 X nutzapped you 2,100 sats" → … | Zap back, View |
| `OnchainZapEvent`(8333) *(new)* | ZAP | colorized amount card | gold / `bolt` | "⛓ X sent an onchain zap" → … | View |
| `ReactionEvent`(7) | REACTION | aggregated `InboxStyle` | heart-red / `favorite` | "🤙 X & N others liked your post" → note excerpt | Like back, View |
| `RepostEvent`(6)/`GenericRepostEvent`(16) *(new)* | REPOST | aggregated | green / `repeat` | "X & N others reposted you" → excerpt | View |
| `PictureEvent`(20)/`VideoNormal`/`Short`/`Vertical`/`Horizontal` | MEDIA | `BigPictureStyle` | purple / `image` | "X shared a photo/video" → caption, image inline | View |
| `PollEvent`/`ZapPollEvent` | MENTION | plain | purple / `ballot` | "X asked a question" → poll title | Vote, View |
| `HighlightEvent`(9802) | ARTICLE | plain | purple / `format_quote` | "X highlighted your article" → highlighted text | View |
| `LongTextNoteEvent`(30023)/`WikiNoteEvent`(30818) | ARTICLE | plain | purple / `article` | "X mentioned you in an article" → title | View |
| `GitIssueEvent`/`GitPatchEvent`/`GitPullRequestEvent`/`…Update` | CODE | plain | slate / `merge` | "X opened an issue/PR" → subject | View |
| `BadgeAwardEvent`(8) *(new)* | BADGE | `BigPictureStyle` (badge art) | gold / `award_star` | "You earned a badge" → badge name, image | View |
| `LiveChessGameAcceptEvent`/`LiveChessMoveEvent` | CHESS | plain | brown / `chess` | "X accepted your challenge" / "your turn" | Open board |
| `CallOfferEvent` | (unchanged — `CallNotifier`/`CallStyle`) | — | — | — | Accept/Reject |
Titles/bodies stay in `strings.xml` (translatable) with plurals for the
aggregate counts (see §7).
---
## 5. Channel restructuring
Channels are a **user-visible, migration-sensitive** surface (users may have
customized importance/sound). Plan:
- **Keep** the six existing channel IDs (DMs, Zaps, Reactions, Replies,
Mentions, Chess) — never rename an ID, it orphans user settings.
- **Add** channels for the newly-rendered categories that don't map cleanly onto
an existing one: **Reposts** (`RepostsID`, LOW), **Media** (`MediaID`,
DEFAULT), **Git/Code** (`CodeID`, DEFAULT), **Badges** (`BadgesID`, DEFAULT).
Nutzaps/onchain fold into the existing **Zaps** channel; articles/highlights
fold into **Mentions**.
- **Group channels** with `NotificationChannelGroup` ("Social", "Payments",
"Messages", "Developer") so the system settings page and our in-app
`NotificationSettingsScreen` read as organized rather than a flat list of 10.
- Register every new channel in `NotificationChannels.contentChannels` (drives
the settings UI) with its Material Symbol + name, matching the existing
`Entry` pattern.
- Reactions/Reposts drop to `IMPORTANCE_LOW` (silent, no heads-up) — they're
ambient; zaps/DMs/replies stay DEFAULT/HIGH.
---
## 6. Capability upgrades (implementation notes)
- **Accent + icon:** add monochrome vector status-bar icons under
`amethyst/src/main/res/drawable/` (they must be white-on-transparent per
Android's small-icon rules — *not* the app logo). `builder.setColor(accent)`;
`setColorized(true)` only for ZAP + CALL. Store accent per category on the
enum.
- **MessagingStyle for replies/mentions/chat:** generalize the existing
`sendDMNotificationStyled` `MessagingStyle` construction into
`render()` for every conversational category. For replies, add the parent note
as an earlier `addMessage(...)` from the original author so the thread shows
context. Reuse the existing `Person` + avatar `IconCompat` code.
- **BigPictureStyle:** in `render()`, when `spec.bigPictureUrl != null`, load it
via the existing Coil `loadBitmap` helper and
`NotificationCompat.BigPictureStyle().bigPicture(bmp)` with the avatar as
large icon; collapse to `BigTextStyle` if the image fails to load.
- **Aggregation:** introduce a small `NotificationAggregator` keyed by
`groupKey` (per-target-note for reactions/zaps/reposts). On each new event it
reads `activeNotifications` for the group, recomputes the roll-up
(participants + count), and re-`notify()`s the same id with an updated
`InboxStyle`/title — mirroring `CardFeedContentState`'s per-target buckets.
Falls back to a single notification below the threshold. Keeps the existing
`sendGroupSummary` behavior for cross-target bundling.
- **Circular avatars:** add a `circleCrop(bitmap)` step in `loadBitmap` (reuse
the `Canvas` approach already in `overlayBadge`). Kind-glyph overlay reuses
`overlayBadge`.
- **Conversations/Bubbles:** publish `ShortcutInfoCompat` (long-lived, with the
partner `Person`) when a DM/reply notification posts; set `shortcutId`,
`addPerson`, and `BubbleMetadata` pointing at a
`MainActivity`/`CallActivity`-style chat entry. Guard by API level.
- **Dedup / dismiss-on-read:** unchanged (`isDuplicate`,
`dismissNotificationForEvent` by `eventId.hashCode()`); aggregation reuses a
stable per-group id so updates replace rather than stack.
---
## 7. Icons & strings work (required side-tasks)
- **Icons (two kinds):**
1. **Status-bar small icons** are Android drawables (white glyphs) — new
vector XML in `res/drawable/`. *Not* Material Symbols font glyphs.
2. **Settings-list icons** in `NotificationChannels` use `MaterialSymbols`. If
any new `MaterialSymbol("\uXXXX")` codepoint is introduced (e.g. `Repeat`,
`Merge`, `AwardStar`, `Ballot`, `Image`) that isn't already referenced,
**regenerate the subset font**: `./tools/material-symbols-subset/subset.sh`
and commit `material_symbols_outlined.ttf` — otherwise it renders as tofu.
- **Strings:** new titles per kind (repost, media, badge, poll, highlight, git,
nutzap, onchain) as translatable resources; aggregate counts become
`<plurals>` (`"%1$s & %2$d others liked your post"`) with the CLDR-category
discipline from `res/CLAUDE.md` (Slavic/Baltic/Arabic `few`/`many`/`zero`).
Use the `pluralStringRes(ctx, …)` helper (non-Compose) in the service path.
---
## 8. Parity gaps to close (behavioral, not just visual)
For these to *render*, two lists must include the kind **and**
`NotificationSpecFactory` must handle it:
- `NotificationDispatcher.NOTIFICATION_KINDS` (Android gate) — add
`NutzapEvent`, `OnchainZapEvent`, `RepostEvent`, `GenericRepostEvent`,
`BadgeAwardEvent`, `LiveActivitiesChatMessageEvent`, NIP-C7 `ChatEvent`.
- `commons/.../NotificationKinds.SUBSCRIPTION_KINDS` (relay REQ list) — must
already carry them so they arrive; verify against
`NotificationKindsContractTest` and extend if needed.
- Keep the in-app `NotificationFeedFilter.NOTIFICATION_KINDS` and the push path
in lockstep — the contract test should assert push-rendered ⊆ in-app-shown.
Decision needed (flagged for the maintainer, §11): reposts/reactions are
*aggregate-only* by design — confirm we want them in the tray at all (many users
consider like/repost notifications noise; hence `IMPORTANCE_LOW` + off-by-default
mirroring the desktop `KindToggles` where reaction/repost default off).
---
## 9. Phased implementation
- **Phase 0 — Refactor to spec+builder (no visual change).** Introduce
`NotificationCategory`, `NotificationSpec`, `NotificationSpecFactory`,
`NotificationUtils.render(spec)`; port the six existing kinds onto it. Prove
parity with existing behavior via unit tests. *No user-visible diff.*
- **Phase 1 — Color & icons.** Accent + status-bar icon per category, circular
avatars, colorized zap card. Highest visual ROI, lowest risk.
- **Phase 2 — Styles.** MessagingStyle for replies/mentions/chat;
BigPictureStyle for media & badges.
- **Phase 3 — Aggregation.** Roll-up for reactions/reposts/zaps via
`NotificationAggregator`; new Reposts channel; LOW importance for ambient
kinds.
- **Phase 4 — Parity kinds.** Route nutzap, onchain zap, repost, badge, live
chat; grow the dispatcher/subscription kind lists; strings + plurals.
- **Phase 5 — Conversations/Bubbles/People + richer actions.** Shortcuts,
bubbles, Zap-back / Like-back / Mute-thread actions.
- **Phase 6 — Channel groups + settings polish.** `NotificationChannelGroup`s;
update `NotificationSettingsScreen`.
Each phase is independently shippable and revertible.
---
## 10. Testing
- **JVM unit tests** for `NotificationSpecFactory.specFor(...)` — table-driven,
one fixture event per kind asserting category/title/body/style/actions. This
is the regression net that lets the six-kind refactor land safely.
- **Contract test** extending `NotificationKindsContractTest`: every kind the
factory produces a spec for is in `NOTIFICATION_KINDS` and `SUBSCRIPTION_KINDS`.
- **Aggregation tests:** feed 1 → 5 reactions on one note, assert a single
updating notification with correct count/participants (mirror
`CardFeedContentState` test fixtures if they exist).
- **Manual matrix** on Android 12 / 14 / 15 (and one heavily-skinned OEM):
screenshot each category light+dark, verify color/icon/heads-up/lockscreen
(public version) and Conversation-section placement for DMs.
- **`amy`**: no CLI surface for tray rendering, but the spec factory is pure
JVM and can be exercised headless.
---
## 11. Risks & decisions for the maintainer
- **Notification volume.** Aggregation + LOW-importance reactions/reposts are
the mitigation; still, **confirm** reactions/reposts belong in the tray
(default-off recommended, matching desktop).
- **Small-icon assets.** Must be true monochrome white glyphs; the current
colored amethyst logo is *not* a valid small icon and is the reason the shade
looks flat today. Needs new drawables.
- **Channel proliferation.** Ten channels risks a cluttered settings page —
hence `NotificationChannelGroup`s; alternatively keep the six and only vary
color/icon/style within them (color/style don't require a new channel; only
independent user importance/sound control does). *Recommend: add only
Reposts + Media + Badges channels, fold the rest.*
- **Font subset drift.** Any new Material Symbol must trigger `subset.sh` or it
renders as tofu — easy to forget; call it out in the PR checklist.
- **Marmot/encrypted content.** MessagingStyle/Bubbles must not leak decrypted
DM/group text to the lockscreen public version — the existing
`setPublicVersion` "New notification arrived" placeholder must be preserved for
all private categories.
---
## Survey (existing components reused vs. new)
- **Reused as-is:** the entire transport + gate layer (`PushWrapDecryptor`,
`NotificationDispatcher` gates, `EventNotificationConsumer` mute/age/self/
known-room checks), Coil `loadBitmap`, `overlayBadge`, `isDuplicate`,
`dismissNotificationForEvent`, `NotificationReplyReceiver` (extended for
new actions), `NotificationChannels`/`NotificationSettingsScreen` structure.
- **Extracted/generalized:** the `MessagingStyle` construction from
`sendDMNotificationStyled` becomes the shared conversational renderer; the six
`send*Notification` builders collapse into `render(spec)`.
- **Genuinely new (Android-only, correct module):** `NotificationCategory`,
`NotificationSpec`, `NotificationSpecFactory`, `NotificationAggregator`,
status-bar drawables, channel groups, shortcut/bubble publishing.
- **Aggregation logic** is a port of `CardFeedContentState.convertToCard`
bucketing — same rules, tray output instead of `Card`s. Consider extracting
the bucketing to `commons` so feed and tray share one implementation.
+199
View File
@@ -0,0 +1,199 @@
# NWC BOLT12 payments (nostr-wallet-connect/nwc#2)
Status: **scoping** — no code yet. Depends on NIP-B1 (this branch) and the
unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47).
## Ecosystem status (2026-07-25) — the wallet gap for real testing
A real NWC-321 (`pay`/`receive`) service exists —
[benthecarman/nostr-wallet-connect-lnd](https://github.com/benthecarman/nostr-wallet-connect-lnd)
— which confirms the protocol we built (Phase 0) is real and adopted. **But it is
LND-backed:** its `pay` "selects and pays a BOLT11 `lightning` instruction from a
BIP-321 URI" and "BOLT12 `lno` instructions are not supported by this LND backend."
So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-B1
zap loop. The blocker is the **node backend**, not the protocol: a CLN- or
LDK-backed NWC-321 service (CLN has full BOLT12 and leads the payer-proof draft
bolts#1346) would support `lno` + return `payer_proof`. Until one exists, the
self-consistent interop harness (`cli/tests/bolt12/`, TODO) is the only way to
exercise the full send → verify → count loop.
## What nwc#2 adds
Two generalized methods replace the bolt11-only `pay_invoice`:
- **`pay`** — params `{ payment: "bitcoin:?lno=lno1…", amount?, payer_note?, metadata? }`.
`payment` is a BIP321 URI (so `bitcoin:?lno=` — exactly what
`payViaBolt12Intent` already builds — or `lightning=`/on-chain). `amount`
(msats) is required only when the instruction has no amount. Result:
`{ transaction_id, state, instruction_type, amount, fees_paid, payment_hash,
preimage, payer_proof: "lnp1…", txid, failure_reason, created_at, settled_at }`.
- **`receive`** — params `{ amount?, description?, metadata? }` → result
`{ bip321: "bitcoin:?lightning=lnbc…&lno=lno1…", transaction_id }`. Lets a
wallet mint our own unified offer; ties to the kind-10058 editor (auto-fill
offers) — later phase.
New errors: `UNSUPPORTED_PAYMENT_INSTRUCTION`, `UNSUPPORTED_NETWORK`. Wallets
advertise support in their kind-13194 info event + `get_info.methods`.
## Two capabilities this unlocks
**A. In-app payment of an offer** — the "extra payment instruction" half. Today
`Bolt12PayButton` fires a `bitcoin:?lno=` intent to an external wallet. With `pay`
we can settle the offer over the user's already-configured NWC connection, no app
switch. No Nostr receipt.
**B. Sending real BOLT12 zaps** — the half deferred since the start of the branch.
The `pay` result carries **`payer_proof: "lnp1…"`**, which is precisely the input
`Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey)` needs. So NWC `pay`
is the payment rail that produces the proof for a kind-9736 zap. This is the
strategic reason to do this now.
## Existing NIP-47 infra we reuse (from the code map)
The send/await/correlate plumbing is **method-agnostic** — it keys on request id
and dispatches the decrypted `Response`, so a new method needs no changes there:
- `NwcSignerState.sendNwcRequestToWallet(uri, request, onResponse)`
(`amethyst/…/model/nip47WalletConnect/NwcSignerState.kt`) — builds the 23194,
synchronous REQ-before-EVENT via `NWCPaymentFilterAssembler`, 60 s timeout,
decrypt-on-arrival.
- `NwcPaymentTracker` (`commons/…/service/nwc/`) — request↔response match with the
author-spoof gate.
- `LocalCache.consume(LnZapPaymentResponseEvent)` — routes 23195 back to the callback.
- Wallet storage: `AccountSettings.nwcWallets` + `defaultPaymentSourceId`;
`PaymentSourceResolver`.
- Pay rail entry: `ZapPaymentHandler.zap()``payViaNWC()` (the `PaymentSource.Nwc`
branch).
Capability discovery exists (`NwcInfoEvent.supportsMethod`, `GetInfoResult.methods`)
but is **not** wired into the pay path — we'd add the gate ourselves.
## Work breakdown
### Phase 0 — quartz protocol (`nip47WalletConnect`)
- `NwcMethod.PAY = "pay"`, `NwcMethod.RECEIVE = "receive"`.
- `rpc/Request.kt`: `PayMethod` + `PayParams(payment, amount, payerNote, metadata)`
with `create(…)`; `ReceiveMethod`/`ReceiveParams`.
- `rpc/Response.kt`: `PaySuccessResponse` (all result fields above, `payerProof`
nullable) and `ReceiveSuccessResponse(bip321, transactionId)`.
- `rpc/NwcErrorCode.kt`: add the two new codes.
- Serializer branches in **both** `Nip47RequestKSerializer` / `Nip47ResponseKSerializer`
**and** the jvmAndroid Jackson variants.
- Optional `Nip47Client.pay(...)` builder.
- Tests: request/response round-trip; a real captured `pay` result fixture.
- No new third-party deps (pure protocol) → licensing clean.
### Phase 1 — in-app offer payment (capability A)
- `Account.sendNwcPayRequest(payment, amount, payerNote, onResponse)` wrapper
(mirrors `sendZapPaymentRequestFor`, reuses `NwcSignerState`).
- In `Bolt12PayButton`'s dialog: when an NWC wallet is configured, add a "Pay with
connected wallet" action (amount-entry sheet, since offers are often amountless)
`pay` with `payment=bitcoin:?lno=<offer>`. Keep the external-intent path as
fallback.
- Surface `UNSUPPORTED_PAYMENT_INSTRUCTION`/`PAYMENT_FAILED`.
### Phase 2 — send BOLT12 zaps (capability B)
- New `Bolt12ZapSender` (or extend `ZapPaymentHandler`): when the recipient has a
kind-10058 offer and the wallet supports `pay`, offer a BOLT12 zap.
1. Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content).
2. `pay` with `payment=bitcoin:?lno=<offer>`, `amount`,
`payer_note="nostr:nipB1:<intent-id>"`.
3. On success with a `payer_proof`, `Bolt12ZapEvent.build(intent, payerProof,
payerPubKey = own | null for anon)`, sign, publish to the recipient's inbox
relays.
4. Our own `LocalCache` consumes the 9736 and counts it.
- Anonymous vs attributed (`P` tag) toggle, mirroring lightning-zap anonymity.
### Phase 3 — gating + receive (later)
- Read `NwcInfoEvent.supportsMethod("pay")` / `get_info.methods` to show the NWC
BOLT12 options only when supported; else fall back to the intent.
- `receive` to mint the user's own offer and pre-fill the kind-10058 editor.
## Risks / open questions (decide before Phase 2)
1. **`payer_note` → `invreq_payer_note` is NOT guaranteed by nwc#2.** The spec only
says "if `payer_note` is not empty, the selected instruction MUST support
payer-provided messages" — it never states the note lands in the BOLT12
`invreq_payer_note`. NIP-B1 binds the zap to the intent through exactly that
field (`invreq_payer_note == nostr:nipB1:<intent-id>`). If a wallet routes
`payer_note` elsewhere, the returned `payer_proof` fails our validator and the
9736 is worthless. **Phase 2 feasibility hinges on this** — needs confirmation
in the nwc thread / a reference wallet, or a follow-up to nwc#2 to nail it down.
2. **`payer_proof` is best-effort** (`"optional if unavailable"`, no wallet mandate).
A wallet may settle the offer and return no proof → payment succeeds but we
can't publish a zap. Phase 1 is unaffected; Phase 2 must degrade gracefully
("paid, but no zap receipt available").
3. ~~**Our verifier can't check compressed proofs yet.**~~ **Resolved.** The
compressed-proof merkle reconstruction shipped and is validated byte-for-byte
against the lightning/bolts#1346 conformance vectors (see
`quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet
(selective-disclosure) proofs now reconstruct and verify, so a bound zap counts
locally as `cryptoVerified = true`.
4. **Maturity.** Both nwc#2 and NIP-B1 are unmerged; few/no wallets implement
`pay` today. Gate hard on capability (Phase 3) and keep the intent fallback.
## Resolution of risks #1/#2 (nwc#2 maintainer, 2026-07-24)
Asked on the nwc#2 thread. Maintainer confirmed:
- **`payer_note` → `invreq_payer_note`**: "that is the intention" for BOLT12 (the
field doubles as a general memo). So our zap binding
(`invreq_payer_note == nostr:nipB1:<intent-id>`) is the intended target.
- **`payer_proof`**: "should be returned for successful bolt12 payments"; the
"optional if unavailable" wording only covers non-BOLT12 instruction types.
Both are informal maintainer intent, not yet spec text, so a non-conforming wallet
is still possible. That's fine: after a `pay` returns, we run the returned
`payer_proof` through `Bolt12ZapValidator` **before** publishing a kind:9736. A
wallet that misroutes the note fails the binding check and we publish nothing —
Phase 2 fails safe, never emitting an invalid receipt.
## Recommendation
Phase 0 (done) and Phase 1 (done) shipped. **Phase 2 is now unblocked.** Build it
with the validate-before-publish gate above; degrade to "paid, no zap receipt"
when the proof is absent or fails validation.
## Phase 2 as shipped (full zap-button integration, BOLT12-first)
Chosen: full integration into the zap pipeline, preferring BOLT12 when the
recipient offers it.
- `Account.sendBolt12Zap(...)` — signs a 9737 intent (ephemeral key for anonymous),
pays over NWC with `payer_note = nostr:nipB1:<intent-id>`, and only on a proof
that passes `Bolt12ZapValidator` self-consumes + publishes the 9736 via
`computeRelayListToBroadcast`. No proof / invalid proof → "paid, no receipt".
- `ZapPaymentHandler.zap()` resolves each recipient's kind:10058 offer and
**partitions** recipients into a BOLT12 lane (offer present AND an NWC wallet is
configured) and the existing lightning lane. Split weights are summed across both
lanes (`totalWeight` threaded into `signAllZapRequests`/`assembleAllInvoices`) so
mixed splits stay proportional. Anonymous/public follows the account zap type.
- `Bolt12ZapBuilderTest` proves the send-side assembly round-trips to a
validator-accepted, crypto-verified zap (attributed and anonymous).
## Phase 3 as shipped (capability gate + lightning fallback)
- `Account.defaultWalletSupportsBolt12Pay()` reads the default wallet's cached
kind:13194 info event via `NwcSignerState.infoCache` (added on `main` for
encryption negotiation; it already refreshes on wallet change) and checks
`supportsMethod("pay")`. A missing/unfetched info event reads as false. (An earlier
cut fetched `get_info.methods` into its own state; unified onto the 13194 cache on
the `main` merge to avoid a redundant fetch — the info event is the canonical
capability advertisement.)
- The zap path's
`canBolt12` now requires it, so a recipient with an offer but a wallet that can't
`pay` **falls back to lightning** via the existing partition instead of erroring.
- `AccountViewModel.canPayBolt12ViaNwc()` gates the profile "pay with wallet" action
on the same signal.
Residual (acceptable): capabilities are empty for the first moment after launch
until `get_info` returns, so a very early zap can miss the BOLT12 rail and use
lightning; and a wallet that doesn't populate `get_info.methods` never gets the
BOLT12 rail even if it supports `pay`. Both fail safe toward lightning.
Known limitations (follow-ons, not blockers):
- ~~**Compressed proofs aren't locally counted.**~~ **Resolved.** Compressed
(selective-disclosure) proofs now reconstruct and verify, so `updateZapTotal`
counts a bound zap locally. Validated against the lightning/bolts#1346
conformance vectors — see `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,808 @@
# Location: foreground-gate the listener, trim the request, fix the meter
Date: 2026-07-29
Module: `amethyst`
Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY,
Pixel 9a / Android 17)
Revision: 2 — incorporates spec review of 2026-07-29
## Context
The 1.13.0 resource report showed **7.13 h of location listening against 9.1
seconds of app foreground**, and the accompanying analysis called it the leading
suspect for that day's 11 pp of background battery drain. Root cause given: 30
`SharingStarted.Eagerly` top-nav filter states on the account scope
(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because
`AccountSettings.kt:256` ships that as the Products default. The `AroundMe`
branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds
`LocationState`'s `WhileSubscribed(5000)` open for the life of the process.
That chain is real. **The battery conclusion drawn from it is not**, and this
spec is written against the measurement rather than the inference.
### What the device reports
`amethyst/src/main/AndroidManifest.xml:80` declares **only**
`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no
`ACCESS_BACKGROUND_LOCATION` — and no service declares
`foregroundServiceType="location"` (the declared types are `mediaPlayback`,
`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`).
`targetSdk = 37`.
`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the
`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate
Location Provider Data* block — not system-wide totals:
| provider | registration held | **active** | **foreground** | fixes |
|---|---|---|---|---|
| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 |
| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 |
| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 |
| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 |
Roughly **11 minutes of background-active location in three weeks**, and about
100 delivered fixes per provider. With the app backgrounded at the time of the
dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware:
mStarted=false`.
The cleanest assumption-free comparison: the ledger's **two-day** `location.ms`
total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total**
(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies.
**One figure does not reconcile, and is left open.** Registration-held is
9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day
across the two ledger days. If `location.ms` measured subscription existence
these should agree; they are ~2× apart. Candidates: segments open at process
death are lost (the pre-flush hook does not run on a kill, and the report shows
6 process starts across the two days); the ledger covers 2 days while dumpsys
spans 21 with different usage. Not investigated. It does not affect the
conclusion below, which rests on `location.ms` versus OS-*active* time, not
versus registration-held.
### How far the "no background location" claim generalises
This matters because the "no behaviour change" argument rests on it, so it is
scoped rather than asserted universally:
- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background
access, and for `targetSdk ≥ 29` an FGS additionally needs
`foregroundServiceType="location"`. Amethyst has neither, so background
registrations are suspended. This is the case the Pixel 9a measurement above
covers.
- **API 2628 (Android 89):** `ACCESS_BACKGROUND_LOCATION` does not exist.
Amethyst *can* receive background location there, throttled by the platform to
a few updates per hour. The gate is a genuine, if small, improvement on these
releases rather than a no-op.
So "structural" applies to API 29+; on 2628 the change has real effect.
### What is actually wrong
1. **The meter lies.** `location.ms` measures how long a *subscription* existed,
not how long anything listened. That is what made Finding 1 read as the
top-priority battery bug.
2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates
`locationManager.allProviders` and calls `requestLocationUpdates` on each —
passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every
one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash.
This burns during the 8 h 14 m the app genuinely is foreground.
3. **The subscription is held for 45 % of device uptime** doing nothing, because
`Eagerly` never lets go.
4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no
opt-in.
5. **Location may be entirely broken on Android 811** — see Hypothesis H1.
## Hypothesis H1 — location is dead below API 31 (unverified)
Through Android 11, AOSP's `getMinimumPermissionForProvider` required
`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only
`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a
coarse-only app request any provider and receive a fuzzed result, is an Android
12 (API 31) change.
Amethyst holds coarse only. So on API 2630 today's `allProviders` loop should
throw `SecurityException` on three of the four providers. Two consequences:
- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState`
`LackPermission`. Location would be **non-functional** on Android 811.
- The builder aborting means `awaitClose` never runs, so `removeUpdates` is
never called and any registration made before the throw **leaks** for the life
of the process.
**The leak is iteration-order dependent, and may not occur at all.**
`getLastKnownLocation` is permission-checked per provider too, and
`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each
iteration. If a fine-only provider comes first in `allProviders``passive`
does, in the common AOSP ordering — the throw lands before any registration
exists: dead, but not leaking. A leak requires `network` to precede a fine-only
provider.
`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint
warning, not the runtime check, so this would not have been caught statically.
**Not reproduced.** Verify before implementing, on the existing
`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes,
and capture **both** the `SecurityException` *and* the actual
`locationManager.allProviders` order (log it). The PR should claim only what that
run observed — "location is dead on Android 811" and "registrations leak" are
separate claims and the second may not hold.
The design below is written to be correct either way (§B excludes
permission-incompatible providers by API level, and catches `SecurityException`
per provider). If H1 holds, this change also **fixes location on Android 811**,
which should be called out in the PR.
### H1 verification result (2026-07-30, Pixel 9a, API 37)
Partial. The API-level claim could **not** be tested on this hardware: API 31+
grants coarse-only apps access to every provider, so no `SecurityException` can
appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it.
What *was* settled is the ordering, which decides the leak sub-claim.
`dumpsys location` recent-events shows the same iteration order on every
registration cycle across two days, all four sharing one registration id
(`88A8E679`), confirming a single `LocationFlow` subscription:
```
07-30 07:09:58.282: passive provider +registration .../88A8E679
07-30 07:09:58.291: network provider +registration .../88A8E679
07-30 07:09:58.293: fused provider +registration .../88A8E679
07-30 07:09:58.299: gps provider +registration .../88A8E679
```
`allProviders` yields **passive first**, and `passive` is one of the fine-only
providers below API 31. So on Android 811 the throw would land on the first
iteration, before any registration exists:
- "location is dead on Android 811" — **still unverified**, needs API ≤ 30.
- "registrations leak" — **disproved for this ordering**. Dead, but not leaking.
The PR must not claim the leak. Caveat: the ordering is observed on API 37 and
`getAllProviders()` could order differently on API 26.
**Unrelated but decisive "before" datum, same session:** with
`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in
`mLastPausedActivity`, Amethyst held **four** live registrations at
`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to
eliminate, captured on the owner's daily-driver device rather than an emulator.
## Goals
- Release the location registration whenever no activity is started.
- Register on one appropriate, permission-compatible provider at an interval
matched to the precision actually needed.
- Make `location.ms` reflect real listening time, correctly, under concurrency.
- No user-visible behaviour change to the "Around Me" feed or geohash chats.
## Non-goals
- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the
gate in place its cost is bounded to foreground use. Worth revisiting
separately as a product decision.
- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`.
- Findings 26 of the source analysis. Finding 2 (the relay reconnect storm,
1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for
the background battery drain and should be taken next.
## Design
### A. The gate
`LocationState` gains an `isForeground: StateFlow<Boolean>` parameter, wired in
`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`,
registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so
initialisation order is safe.
Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate
over *permission × foreground*, applied identically to `geohashStateFlow` and
`preciseGeohashStateFlow`:
| gate state | behaviour |
|---|---|
| no permission | emit `LackPermission` (unchanged) |
| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` |
| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value |
**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses
to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate
would make the "Around Me" feed flash empty on **every** return to foreground — a
new, frequent, user-visible regression introduced by this change. (It also fixes
the same flash on permission grant, which exists today.)
**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's
code emits `LackPermission` without touching `latestLocation`
(`LocationState.kt:94-96`), and that stays. Clearing it is superficially
attractive — a revoked permission arguably should not leave a fix readable — but
consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is
private and its only jobs are seeding `stateIn` and deciding whether `Loading` is
emitted. Clearing it would therefore buy no privacy and would cost an
empty-feed flash on every permission flap, which is precisely what R1 exists to
prevent. The cache is in-memory and dies with the process regardless.
The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry
looks arbitrary next to the paragraph above, so to be explicit: it is inherited,
not introduced. Both branches preserve today's behaviour exactly
(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing
permission). This corollary argues against *adding* a clear, not for removing
the existing one — changing it would be an unmotivated behaviour change. The
asymmetry is also defensible on its own terms: a source that failed mid-stream
says something about the fix's provenance, whereas a permission known to be
absent says nothing about a fix already taken.
**R2 — grace period on the background edge.** The gate must delay the
`foreground → background` transition by **5 s** before tearing down. Without it a
one-second app switch destroys and rebuilds the registration, including a full
`getLastKnownLocation` sweep, so a user flipping between apps pays more than the
steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same
intent. The `background → foreground` edge is **not** delayed.
Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)`
delays both edges, and the duration-selector overload that would allow an
asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file
and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`:
```kotlin
isForeground.transformLatest { fg ->
if (!fg) delay(BACKGROUND_GRACE_MS)
emit(fg)
}
```
`transformLatest` cancels the pending `delay` if foreground returns first, which
is exactly the stated semantics, with no preview opt-in.
**R3 — the retained-value contract.** The "emit nothing" branch is what keeps
this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60
synchronous `.value` reads across the feed filters
(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`,
`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km
cell does not meaningfully decay while backgrounded.
**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation`
(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial
values. R1 promotes them to control flow, read from a different coroutine than
the `onEach` that writes them. They must become `@Volatile` (or
`MutableStateFlow`).
**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly`
to `WhileSubscribed`. Roughly 60 call sites read
`account.live*FollowLists.value` synchronously rather than collecting; under
`WhileSubscribed` those reads would silently serve a stale or initial value
whenever no collector happened to be active. That is a correctness regression,
not a battery fix.
**Rejected alternative:** gating only at the `AppModules` wiring point
(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw
`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for
`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the
same data.
### B. Request shape
`LocationFlow.get` registers on **one** provider, chosen by a ladder over
**provider existence and permission compatibility** — both static facts:
```
chooseProviders(sdkInt, hasFine, exists) -> List<String>:
API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists
API < 31, coarse → [NETWORK] filtered by exists (see H1)
```
It returns the **ordered candidate list**, not a single choice, because the
per-provider `SecurityException` fall-through below needs somewhere to fall to.
An empty list means no compatible provider exists.
**The ladder deliberately does not consult `isProviderEnabled`.** Today's code
registers regardless of enabled state, and such a registration goes live by
itself when the user enables location — including from the quick-settings shade
without leaving the app, which is exactly what someone does after seeing "Around
Me" empty. A guard evaluated once at subscription start would lose that, and the
foreground-transition restart does not cover the in-app path. Selecting on
existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If
field reports show dead feeds on devices where the chosen provider exists but is
disabled while another is enabled, adding that receiver is the follow-up.
`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch
that falls through to the next rung, so H1 cannot abort the builder and leak
registrations regardless of how the AOSP check actually behaves.
**When no provider can be registered** — the candidate list was empty, or every
rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit
`LackPermission`: the seam is `(Long, Float) -> Flow<Location>`, and
`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no
way to express. Throwing routes it through the `.catch` already present in
`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets
`latestLocation = LackPermission` and emits it — the existing, unchanged path.
Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag
guard: the throw happens before the acquire, so `onListening(true)` cannot fire
without a live registration and no separate flag is needed. That is only half of
R5's pairing, though — see R5 for the release half, which the throw does **not**
cover and which needs `try`/`finally`.
**Stated decision: `LackPermission` stays conflated with "no usable provider".**
That value renders `R.string.lack_location_permissions` — "No Location
Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`,
which is wrong for a coarse-only pre-31 device that has no `network` provider.
The conflation is accepted rather than introduced: if H1 holds, today's
`SecurityException` already lands in the same `.catch` and shows the same wrong
message. Adding an `Unavailable` state would ripple through four UI `when`s plus
`LocationState` (10 references across 5 files) and belongs with the H1 fix
messaging, not here. Recorded as a follow-up.
The `getLastKnownLocation` seed stays a sweep across all providers, taking the
freshest result. It requires no registration and is what makes the first geohash
appear immediately rather than after a fix.
`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call:
| flow | precision | interval / distance | provider set |
|---|---|---|---|
| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 |
| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 |
Both rows change: the ladder narrows the precise flow's provider set too, and
below API 31 that means `network` only, no GPS. Academic while the app holds
coarse only (see Follow-ups), but it is not "unchanged".
At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no
observable effect on the feed.
**Rejected alternative — one shared source at the fine profile,** deriving the
coarse geohash by prefix truncation. It halves registrations and removes the need
for `RefCountedSession` entirely, but it upgrades the **common** case — the
"Around Me" feed alone, which is always on via the Products default — from
60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one.
**Rejected alternative — one shared source whose profile tracks the finest
active subscriber.** Recovers the above and is the best of the three on both
axes, but it is refcounting with the counter moved from the meter into the
request path, for a benefit bounded by how often the two flows overlap. They
overlap only while one of three composable-scoped, foreground-only screens is
open (`GeohashChatScreen`, `NewGeohashChatScreen`,
`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes.
### C. The meter
`AppModules.kt:251` hands both flows the same non-refcounted
`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment
while the other is still listening. Both can be live at once — the "Around Me"
feed plus an open geohash chat.
**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today
`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`,
so it fires on *collection* whether or not anything was registered — meaning a
device with no usable provider accrues `location.ms` with nothing listening,
reintroducing the exact defect this section exists to fix. The hook must instead
fire from inside the `callbackFlow`, after `requestLocationUpdates` returns
without throwing, and again on the way out.
The obvious "way out" is `awaitClose`, and that would introduce a worse bug than
it fixes — twice over. First, `awaitClose` runs on every normal
completion, including one where no rung ever registered, so it would fire an
**unpaired** `onListening(false)`. With R6 that does not merely under-count — it
decrements a holder it never acquired, stealing another flow's. Concretely:
`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to
register and closes (`holders = 0`), and the session latches off while the coarse
flow is still listening. `coerceAtLeast(0)` does not help; the count never went
negative.
Second — and this is the one that survives fixing the first — `awaitClose` also
fails to run at all on some paths that *did* register. See below.
The pair therefore has to be guaranteed from **both** ends, and the two ends need
different mechanisms.
*No acquire without a registration* is §B's **throw**: if no rung registers, the
builder throws before reaching the acquire at all.
*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This
is the subtlest point in the document, so the justification below is the one that
was **demonstrated**, not the one that sounds most obvious.
Anything between the acquire and `awaitClose` that unwinds skips cleanup parked
inside `awaitClose`, because `awaitClose` is never reached to register it. The
registration then leaks and the refcount sticks at ≥ 1 for the life of the
process, so `location.ms` accrues forever with nothing listening — this exact
defect, arrived at from the other direction, and unrecoverable once hit.
The **proven** path is the seed throwing a non-cancellation exception:
`getLastKnownLocation` is a binder call and can fail. The regression test
`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched
failing against an `awaitClose`-only implementation
(`expected:<[true, false]> but was:<[true]>`).
A cancellation during the seed is *in principle* a second such path, since `send`
is a suspending call. Recorded honestly: **this one could not be reproduced.**
Two attempts during implementation both produced tests that passed against a
deliberately broken implementation, because `callbackFlow`'s channel is buffered,
so `send` returns without suspending and never observes the cancel. Do not treat
the cancellation story as the reason for the `try`/`finally`; a future reader who
tries to reproduce it, fails, and concludes the guard is unnecessary would
reintroduce the leak.
```kotlin
var registered: String? = null
for (provider in candidates) {
try {
locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper())
registered = provider
break
} catch (e: SecurityException) { /* next rung */ }
}
if (registered == null) throw SecurityException("no usable location provider")
onListening?.invoke(true) // cannot fire without a registration
try {
freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable
awaitClose { } // only to satisfy callbackFlow's contract
} finally {
locationManager.removeUpdates(callback)
onListening?.invoke(false) // cannot be skipped
}
```
`onListening?.invoke(true)` sits immediately before the `try`, with no suspension
between them, so the acquire cannot happen outside the block that guarantees its
release.
`trySend` for the seed would also close this particular hole, being
non-suspending. It is rejected because it leaves the invariant resting on nobody
adding a suspending call to that block later — vigilance rather than
impossibility, which is the standard the rest of R5 is held to.
**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not
sufficient: two threads can leave the counter at 1 while the last
`setActive(false)` lands after the `setActive(true)`, latching the session off.
The count and the transition must move under one lock. New class in
`service/resourceusage/`:
```kotlin
class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) {
private val lock = Any()
private var holders = 0
fun setActive(active: Boolean) =
synchronized(lock) {
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
setSessionActive(holders > 0)
}
}
```
It takes the setter as a lambda rather than a `SessionTimeIntegrator` because
that is all it needs, and because constructing a real integrator in a unit test
would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp
file to observe one boolean.
`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes
`onListening = { locationRefCount.setActive(it) }`. The outer
lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is
then nested but never acquired in the reverse order, so there is no deadlock.
`coerceAtLeast(0)` guards an unmatched release.
### What `location.ms` means after this change
Stated plainly, because the finding that opened this spec is "the meter lies"
and the next reader should not over-trust the fixed number the way the last one
over-trusted the broken one:
> `location.ms` measures **how long a location registration was held while the
> app was in the foreground**. It is not radio-on time and not an energy
> figure. A `network`-provider registration at 60 s costs close to nothing; a
> `gps` registration at 10 s costs a great deal. The counter cannot tell them
> apart.
Reading it as a battery signal requires knowing which provider was chosen —
which the ledger does not record. Recording the chosen provider as a separate
counter is a possible follow-up.
## Testing
JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric,
alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`.
**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow<Location>`,
defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get`
(see *Registration pairing* for why `LocationFlow` now takes the manager rather
than the `Context`). That is the seam:
`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure
Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a
`mockk<Location>` with stubbed `latitude`/`longitude` suffices. Against a
counting fake source:
- backgrounded + permitted → source never subscribed
- foreground + permitted → subscribed exactly once
- foreground → background → subscription released after the R2 grace period,
last `Success` still readable via `.value`
- background edge shorter than the grace period → subscription **not** torn down
- return to foreground with a cached `Success`**no** `Loading` emission (R1)
- return to foreground with no cached fix → `Loading` first
- permission revoked → `LackPermission` regardless of foreground state
**Provider ladder.** Extracted as a pure function
`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean):
List<String>` so §B is covered rather than sitting below the seam. Cases: rungs
returned in order; missing rungs filtered out; API < 31 coarse-only yields
`[network]`; API < 31 with fine yields the full ladder; no compatible provider
yields an empty list.
`hasFine` is **always `false` in production** — the non-goals rule out ever
requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so
that the function is total over the permission axis and the API < 31 branch can
be tested from both sides, not because fine access is anticipated. If that
changes, the ladder is already correct.
R5 sits below the `locationSource` seam, so a fake source never fires it. It gets
its own tests against a mocked `LocationManager` — see *Registration pairing*
below.
**Meter.** `RefCountedSession`: overlapping holders keep the session open;
balanced pairs close it; an unmatched release does not drive the count negative.
Note what this class **cannot** do: it cannot distinguish an unpaired release
from a legitimate one, so `acquire → unpaired release` closes the session even
while another holder is listening. That is precisely the R5 bug, and
`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to
`LocationFlow`, not here** — which is why it needs its own test below.
**Registration pairing (R5).** To make this testable rather than device-only,
`LocationFlow` takes a `LocationManager` instead of a `Context`
(`LocationFlow(context)``LocationFlow(locationManager)`; the caller in
`LocationState` does the `getSystemService` lookup). A `mockk<LocationManager>`
then covers:
- every rung throws `SecurityException` → the flow throws and `onListening` fires
**neither** edge
- `chooseProviders` returns an empty list → same: throws, neither edge
- an earlier rung throws and a later one succeeds → registration falls through,
exactly one `true`
- a successful registration → exactly one `true`, and exactly one `false` plus
`removeUpdates` on cancellation
- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight
→ both edges still fire and `removeUpdates` is still called. This is the case
the `try`/`finally` exists for; without it the test fails by hanging the
refcount at 1 rather than by throwing, so assert on the edges, not on the
absence of an exception.
These cover the *semantics* only — the two-thread interleaving that motivates the
lock is made unobservable by the lock itself and is not reproduced by any test
here.
## Acceptance criteria
On device, re-running the measurement above:
- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location`
shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`,
and a `-registration` in the recent-events log.
- **Foregrounded:** **one registration per actively-collected flow — at most
two**, and one in the steady state where only the "Around Me" feed is live
(§C exists precisely because the two flows may overlap). Not four. The coarse
registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than
`@+10s0ms` / `100.0`.
- The historical aggregates are cumulative since boot; compare deltas across a
foreground/background cycle, not absolute totals.
- **Invariant:** a subsequent in-app Resource Usage Report shows
```
location.ms ≤ app.fgms + 5 s × (background transitions)
```
Both counters are driven by the same `foregroundTracker.isForeground` flow, so
without R2 this would hold exactly. R2 is deliberately the error term: the
registration really *is* live during the grace period, so counting it is the
honest reading, and a stated fudge factor beats an invariant quietly known to
be false. The term is not negligible — the source report shows 6 process
starts across two days, and app switches are far more frequent than that — so
writing `location.ms ≤ app.fgms` would guarantee that the first person to
check it files a bug against this change.
Second caveat: Finding 4 of the source analysis suspects `app.fgms` of
under-reporting, so a violation beyond the grace term indicts that counter
rather than this one.
- If H1 holds: location works on the API 26 AVD after the change and did not
before.
### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37)
Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with
the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark`
beside the untouched Play install. The Play install was force-stopped for the
duration so its own (unfixed) registrations could not be mistaken for these.
| criterion | before | after |
|---|---|---|
| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) |
| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` |
| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** |
Event trace for one full cycle, process alive throughout (pid 28682):
```
17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0
17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace
17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0
(HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives)
```
- **§A gate** — zero registrations while backgrounded, with the process still
alive. That is the state the change exists to create; before, four
registrations survived screen-off and doze.
- **§B request shape** — one provider, top of the ladder (`fused`), at exactly
`COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and
coalesces the effective service request to `@+10m0s0ms LOW_POWER`.
- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild
pair, so a brief switch no longer costs a re-registration and a fresh
`getLastKnownLocation` sweep.
**The OS aggregate after four foreground/background cycles is the headline
result**, because it is the same counter shape `location.ms` measures:
```
com.vitorpamplona.amethyst.benchmark:
min/max interval = 60s/60s
total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms
locations = 4
```
Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's
`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while
only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the
aggregate exactly, so nothing is held outside them. Registration-held time now
*equals* foreground time, which is precisely what makes `location.ms` honest: the
counter measures registration lifetime, and that quantity is no longer divorced
from reality.
**A fix arrives within milliseconds of every re-registration**, which bounds the
staleness the coarser profile was feared to introduce:
```
17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms)
17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms)
17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms)
18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms)
```
The `fused` provider hands over its cached fix on registration, so the window in
which a returning user could act on a stale geohash is milliseconds, not the 60 s
poll interval. Caveat: that cache is warm on this device because Maps and GMS
keep it fresh; on a device with no other location consumer it could be colder,
which is what `freshestLastKnownLocation` exists to cover.
**Side-by-side A/B, same device, same instant, both clients backgrounded and
running.** The unmodified release client (1.13.1, installed via Obtainium, pid
5552) and the benchmark build of this branch (pid 28682) were sampled together:
```
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
← com.vitorpamplona.amethyst.benchmark: no rows at all
```
Four held registrations versus zero. Note the release client's rows are all
`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10
minutes and suspended delivery, exactly as §"What the device reports" describes —
but the **registration is still held**, and registration-held time is precisely
what `location.ms` counts. That is the inflation, visible in one frame.
Naming note for anyone re-reading the numbers above: both artifacts are `play`
**flavor** builds and differ only by buildType, so "the Play install" is an
ambiguous label. The unmodified client here is the *release* build, and on this
device it came from Obtainium rather than Google Play.
### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report)
The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks
out against accumulated data:
| | `location.ms` | `app.fgms` | ratio |
|---|---|---|---|
| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** |
| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× |
| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** |
Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which
requires ≥ 3 background transitions to fall inside the grace allowance — met by
the report navigation plus an `am start`. Day 20664 independently reconciles with
the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace =
~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the
measurement ended. **The ledger and the OS now agree**, where before they were
irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks).
**Limitation:** this is not a within-package before/after. `location.ms` is
absent from days 2064820663 because the benchmark client had location permission
*denied* until 2026-07-30; the "before" is no data, not inflated data.
### The same data closes the battery question
Over days 2065920665 on this device: **5m18s** of location listening against
**596 pp** of background battery drain (~85 pp/day). Location cannot be a
meaningful contributor at that ratio — Finding 1 is settled, and not in the
direction the original analysis assumed.
Three consumers visible in the same report, none of them location:
- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on
foreground service running essentially continuously. Largest structural
difference from a stock client; worth confirming it is deliberately enabled.
- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone:
9,831 successful dials against 25,133 failures = **71.9 %**, matching the
original report's 75 %.
- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems =
52,392,613` — **14.6 h** of background mobile active time with **0 requests and
0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so
the fg/bg split in this report still cannot be trusted.
Caveat: the benchmark client's round-the-clock always-on service makes its
battery figures non-comparable to a stock install. The relay and `net.other`
figures do match the release client's original report closely.
### Smoke test on a clean install (2026-07-31, benchmark client)
Uninstalled and reinstalled from branch HEAD so the account, ledger and
permission grant all started empty — which is what makes the first-grant and
empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the
new data.
- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly:
granted the permission, set a feed to Around Me, backgrounded for 10 s,
reopened — the geohash was present immediately with no blank feed. This was the
last open acceptance criterion on the branch and the only one no test could
cover. `dumpsys` shows why it works: the fix is delivered 16 ms after each
re-registration.
- **Precise profile live and distinct: PASS.** Geohash screens produce
`@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max
interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in
production, not just in the unit tests.
- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4`
came up at 15:49:10 and survived **four complete precise-flow cycles**
untouched (15:49:4146, 15:50:4752, 15:52:0715:53:05, 15:53:1722), with
both live simultaneously during the first. Opening and closing geohash chats
never closes the "Around Me" registration — `RefCountedSession` doing on a real
device what `LocationLedgerCompositionTest` asserts.
- **No hang in the location picker.** Every precise registration received a fix
within ~1 ms; none stuck open. That path does
`preciseGeohashStateFlow.first { it is Success }`, which would hang rather than
crash if the gate failed to yield.
- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground
5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions,
≈ 2.6 s each — the grace period, visible at a scale where it is easy to check.
**Measurement note:** counting listeners with `grep -c` on the package name is
unreliable — the `service: ProviderRequest[…]` summary line also names the
package when it is the only requester, inflating the count by one. List the rows
and exclude that line instead. A count of zero is unambiguous either way.
Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms +
5 s × transitions` invariant was separately confirmed from accumulated ledger
data (see above); the clean install reset that counter, so it will need another
day of use to re-check at scale.
## Follow-ups (not in this change)
- **`preciseGeohashStateFlow` is not actually building-level.** With only
`ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so
the 8-char geohash and the location chat channels built on it are far coarser
than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`,
`NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent
survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request
it, or to stop advertising building-level precision, is a separate decision.
- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls
`setLocationPermission(true)` inside an `if (isGranted)` rather than passing
the boolean, as every other caller does (`LoggedInPage.kt:144`,
`LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`,
`GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never
reflected back into the shared `LocationState`. Small, in the blast radius,
and cheap.
- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a
device with no usable provider stops being told "No Location Permissions" when
it has them. Ten references across five files
(`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`,
`NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix
messaging — see the stated decision in §B.
- Recording the chosen provider as a ledger counter, so `location.ms` can be
read as a cost signal.
- Whether Products should still default to `TopFilter.AroundMe`.
- Finding 2 — the relay reconnect storm.
+1
View File
@@ -10,6 +10,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
## Archived (shipped)
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
@@ -86,7 +85,6 @@ class NotificationFeedFilterModeOverrideTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -77,7 +76,6 @@ class ThreadDualAxisChartAssemblerTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
@@ -0,0 +1,250 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import io.mockk.mockk
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The "open in browser" fallback is the only thing this overlay offers the user, so it has to
* survive whatever box the media layout hands it.
*
* [Column] measures children in declaration order against the remaining height, so the button —
* being last — is what starved when the content was taller than the box. A note in the feed is
* inset under the 55dp author column (screenWidth - 89dp), and with no imeta `dim` the media box
* is 16:9, which on a 411dp phone is 322dp wide and only 181dp tall. That was enough to squeeze
* the button down to 0.38dp — present in the tree, invisible and untappable on screen — while the
* same note opened in the thread (full bleed, screenWidth - 26dp, so a 217dp box) rendered it at
* 35.8dp.
*/
@RunWith(AndroidJUnit4::class)
class PlaybackErrorOverlayFitTest {
@get:Rule val rule = createComposeRule()
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
private fun renderInBox(
width: Dp,
height: Dp,
fontScale: Float = 1f,
) {
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, fontScale)) {
Box(Modifier.width(width).height(height)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
}
}
}
private fun assertButtonUsable() {
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
.assertIsDisplayed()
.assertHeightIsAtLeast(ButtonDefaults.MinHeight)
}
@Test
fun buttonAndTitleSurviveTheFeedsSixteenByNineBox() {
renderInBox(width = 322.dp, height = 181.dp)
assertButtonUsable()
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
.assertIsDisplayed()
}
@Test
fun buttonSurvivesTheThreadsSixteenByNineBox() {
renderInBox(width = 385.dp, height = 217.dp)
assertButtonUsable()
}
@Test
fun shortBoxKeepsTheButtonAndDropsTheDescription() {
// A 3:1 banner-ish stream, or a narrow quote card: far less height than 16:9 gives. A
// weighted Text handed less than one line's height draws it clipped through the middle,
// which looks broken — under that much pressure it should not be emitted at all.
renderInBox(width = 322.dp, height = 110.dp)
assertButtonUsable()
rule
.onNodeWithText(
stringRes(
targetContext,
R.string.error_video_playback_failed_description,
"ERROR_CODE_PARSING_MANIFEST_MALFORMED",
),
).assertDoesNotExist()
}
@Test
fun buttonSurvivesLargeFontScale() {
// At fontScale 2 the text wants far more height than the dp thresholds were tuned for.
// The button must still get its intrinsic height, because it is measured before the
// weighted text block — the guarantee is structural, not numeric.
renderInBox(width = 322.dp, height = 195.dp, fontScale = 2f)
assertButtonUsable()
}
@Test
fun shrinkingTheBoxNeverShrinksTheButton() {
// ButtonDefaults.MinHeight alone does not pin the guarantee: a button squeezed from its
// intrinsic 53dp down to 40dp at fontScale 2 still clears that floor. What actually has to
// hold is that the box height cannot influence the button's height at all, because the
// button is measured before the weighted text block that absorbs the shortfall. Measure
// the same button roomy and then at its tightest, and require the two to agree.
val boxHeight = mutableStateOf(400.dp)
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
}
}
val roomy = buttonHeight()
// 190dp is the worst case for the old threshold-only layout: just enough to keep the icon,
// not enough to pay for it, so the shortfall landed on the button.
rule.runOnUiThread { boxHeight.value = 190.dp }
rule.waitForIdle()
val tight = buttonHeight()
assertTrue(
"button shrank from $roomy to $tight when the box did",
tight >= roomy - 1.dp,
)
}
@Test
fun theIconNeverCostsTheTitleItsHeight() {
// The icon is non-weighted and declared first, so inside the weighted text block it is
// measured before the title. With a fixed 190dp gate, a box of 190dp to 199dp at fontScale 2
// was just tall enough to keep the icon and not tall enough to pay for it, so the title
// rendered sliced. Decoration must yield before words do.
val boxHeight = mutableStateOf(400.dp)
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
}
}
val roomy = titleHeight()
rule.runOnUiThread { boxHeight.value = 195.dp }
rule.waitForIdle()
val tight = titleHeight()
assertTrue(
"title sliced from $roomy to $tight to make room for the decorative icon",
tight >= roomy - 1.dp,
)
}
private fun titleHeight(): Dp {
val bounds =
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
.getUnclippedBoundsInRoot()
return bounds.bottom - bounds.top
}
private fun buttonHeight(): Dp {
val bounds =
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
.getUnclippedBoundsInRoot()
return bounds.bottom - bounds.top
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The inline audio player sizes itself as a square so the visualizer and controls get room, which is
* taller than the 16:9 box [ZoomableContentView] builds for a video of unknown dimensions. That box
* is centre-aligned and nothing between it and NoteComposeLayout clips, so caging the square in it
* makes the player paint over the note header above and the reactions row below.
*
* These tests recreate that enclosure — the real [mediaSizingModifier] over the real
* [unknownMediaAspectRatio], holding the real [audioSquare] — and pin both halves of the contract:
* audio must not be given a ratio, and video must keep the 16:9 assumption that stops live streams
* from letterboxing on first play.
*/
@RunWith(AndroidJUnit4::class)
class AudioPlayerBoxOverflowTest {
@get:Rule val rule = createComposeRule()
private class Bounds {
var top = 0f
var bottom = 0f
var width = 0
var height = 0
fun modifier() =
Modifier.onGloballyPositioned {
top = it.positionInRoot().y
width = it.size.width
height = it.size.height
bottom = top + height
}
}
private fun measure(
url: String,
square: Boolean,
): Pair<Bounds, Bounds> {
val box = Bounds()
val player = Bounds()
rule.setContent {
Box(Modifier.size(width = 400.dp, height = 1200.dp)) {
Box(
modifier = mediaSizingModifier(unknownMediaAspectRatio(null, url), ContentScale.Fit).then(box.modifier()),
contentAlignment = Alignment.Center,
) {
Box((if (square) Modifier.audioSquare() else Modifier).then(player.modifier()))
}
}
}
rule.waitForIdle()
return box to player
}
@Test
fun squareAudioPlayerStaysInsideItsMediaBox() {
val (box, player) = measure("https://haven.sdbitcoiners.com/f28a5a2e.mp3", square = true)
assertTrue(
"player overflows above the media box by ${box.top - player.top}px",
player.top >= box.top,
)
assertTrue(
"player overflows below the media box by ${player.bottom - box.bottom}px",
player.bottom <= box.bottom,
)
assertEquals("the box should wrap the square", player.height, box.height)
}
@Test
fun videoOfUnknownSizeKeeps16by9() {
val (box, _) = measure("https://example.com/a.mp4", square = false)
assertEquals(16f / 9f, box.width.toFloat() / box.height, 0.01f)
}
}
@@ -97,8 +97,8 @@ class PushMessageReceiver : MessagingReceiver() {
Log.d(TAG) { "Building okHttpClient, useTor: ${Amethyst.instance.torManager.isSocksReady()}" }
Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady())
}
NotificationUtils.getOrCreateZapChannel(appContext)
NotificationUtils.getOrCreateDMChannel(appContext)
NotificationCategory.ZAP.ensureChannel(appContext)
NotificationCategory.DIRECT_MESSAGE.ensureChannel(appContext)
}
// } else {
Log.d(TAG) { "Same endpoint provided:- ${endpoint.url} for Instance: $instance $sanitizedEndpoint" }
@@ -66,3 +66,10 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) = displayText(content)
/** No translation service in this flavor, so the content is always its own "translation". */
@Composable
fun rememberTranslation(
content: String,
accountViewModel: AccountViewModel,
): String = content
+98
View File
@@ -212,6 +212,17 @@
<data android:pathPrefix="/invite/" />
</intent-filter>
<!-- Buzz workspace invite links: https://<team>.communities.buzz.xyz/invite/<token> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="communities.buzz.xyz" />
<data android:host="*.communities.buzz.xyz" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -284,6 +295,93 @@
</intent-filter>
</activity-alias>
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsPictureAlias"
android:exported="true"
android:label="@string/share_target_as_picture"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsShortVideoAlias"
android:exported="true"
android:label="@string/share_target_as_short_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsVideoAlias"
android:exported="true"
android:label="@string/share_target_as_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
passage, so no image/video filters here. The android:name simple class
("ShareAsHighlightAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsHighlightAlias"
android:exported="true"
android:label="@string/share_target_as_highlight"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_highlight">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
the permission request fails silently (no dialog appears). The system launches it,
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
@@ -56,11 +56,36 @@ import java.io.File
*/
class Amethyst : Application() {
init {
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
Log.minLevel = DEFAULT_LOG_LEVEL
Log.d("AmethystApp") { "Creating App $this" }
}
companion object {
/**
* Restores the full firehose in debug builds: per-socket relay lifecycle
* (`Connecting…`/`Disconnected`/`OnOpen`), per-second event throughput, per-stream Arti
* SOCKS errors and per-relay census detail.
*
* Off by default. A cold start on the outbox model dials ~400 relays, and those sources
* alone emit ~3.7k of the ~6.2k lines an 80s boot produces — which buries the boot
* narrative and the relay-protocol warnings (`auth-required`, `rate-limited`,
* `unsupported: too many filters`) that are the actionable ones. Flip this, or set
* [Log.minLevel] at runtime, when you need the per-socket detail back.
*/
const val VERBOSE_LOGS = false
/**
* Debug defaults to INFO so a boot reads as a narrative plus warnings; release defaults to
* WARN rather than ERROR so relay-protocol refusals stay visible in the field — they are
* the highest-value-per-line diagnostics we emit and were previously dropped entirely.
*/
val DEFAULT_LOG_LEVEL: LogLevel =
when {
!BuildConfig.DEBUG -> LogLevel.WARN
VERBOSE_LOGS -> LogLevel.DEBUG
else -> LogLevel.INFO
}
lateinit var instance: AppModules
private set
}
@@ -83,10 +108,15 @@ class Amethyst : Application() {
// is self-contained (WebView + IPC), so we skip all app init there and
// leave `instance` unset; any accidental use fails fast.
if (isNappletSandbox) {
Log.d("AmethystApp") { "Skipping AppModules init in sandbox process" }
// Milestone, not chatter: this is the one line that explains why `instance` is unset
// and `LocalCache` is empty in this process. Without it a napplet-process log looks
// like a broken app rather than a deliberately secret-free sandbox.
Log.i("AmethystApp") { "Napplet sandbox process starting — no account, no AppModules" }
return
}
Log.i("AmethystApp") { "Amethyst ${BuildConfig.VERSION_NAME} starting in main process (log level ${Log.minLevel})" }
instance = AppModules(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
@@ -46,8 +46,12 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
@@ -68,7 +72,10 @@ import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
@@ -88,6 +95,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor
import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
@@ -98,6 +106,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession
import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
@@ -134,7 +143,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDe
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
@@ -240,10 +248,17 @@ class AppModules(
OtsSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their flows
// App services that should be run as soon as there are subscribers to their
// flows. Location additionally releases its OS registration whenever no
// activity is started — see the foreground gate inside LocationState.
val locationManager by lazy {
Log.d("AppModules", "LocationManager Init")
LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) })
LocationState(
appContext,
applicationIOScope,
isForeground = foregroundTracker.isForeground,
onListening = { locationRefCount.setActive(it) },
)
}
val connManager = ConnectivityManager(appContext, applicationIOScope)
@@ -270,6 +285,23 @@ class AppModules(
}
}
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -351,6 +383,11 @@ class AppModules(
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
// LocationState exposes two independent flows that can both be listening at
// once (the "Around Me" feed plus an open geohash chat). Refcounting keeps
// either one stopping from closing the other's segment.
private val locationRefCount = RefCountedSession(locationSession::setActive)
// Time-per-screen (route base names only — arguments never reach the
// ledger). Fed by the navigation listener in AppNavigation; foreground
// gating means backgrounding on a screen closes its segment.
@@ -409,6 +446,16 @@ class AppModules(
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
// Retries auth-gated Blossom blob downloads (e.g. Buzz's private
// media relay) with a BUD-01 read-auth token signed by the current
// account on a 401. Reads the signer at call time so it always
// tracks the logged-in account.
blossomReadAuth =
BlossomReadAuthInterceptor(
BlossomReadAuthTokenProvider(
signerProvider = { sessionManager.loggedInAccount()?.signer },
)::authHeader,
),
)
// Offers easy methods to know when connections are happening through Tor or not
@@ -562,12 +609,6 @@ class AppModules(
)
}
// Application-wide ots verification cache
val otsVerifCache by lazy {
Log.d("AppModules", "OtsCache Init")
VerificationStateCache(otsResolverBuilder)
}
val torEvaluatorFlow =
TorRelayState(
okHttpClients,
@@ -732,7 +773,7 @@ class AppModules(
// Tries to verify new OTS events when they arrive.
val otsEventVerifier =
IncomingOtsEventVerifier(
otsVerifCache = { otsVerifCache },
otsResolverBuilder = otsResolverBuilder,
cache = cache,
scope = applicationIOScope,
)
@@ -844,7 +885,6 @@ class AppModules(
AccountCacheState(
geolocationFlow = { locationManager.geohashStateFlow },
nwcFilterAssembler = { sources.nwc },
cashuWalletFilterAssembler = { sources.cashuWallet },
cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory },
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
contentResolverFn = { appContext.contentResolver },
@@ -867,6 +907,17 @@ class AppModules(
scope = applicationIOScope,
)
// Surfaces non-zap Lightning payments reported by the logged-in account's NWC
// wallet(s) as tray notifications (zaps are already shown via ZapNotification).
// The relay subscription lives in the always-on AccountFilterAssembler; this
// only drains the decoded-payment flow into an OS notification.
val nwcPaymentNotificationWatcher =
NwcPaymentNotificationWatcher(
context = appContext,
scope = applicationIOScope,
accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account },
).also { it.start() }
fun subscribedFlow(
address: Address,
account: Account,
@@ -919,6 +970,12 @@ class AppModules(
)
}
// Gives every account that opted into running in the background its own
// account-level subscriptions (notifications, DMs, gift wraps, wallet), with no
// AccountViewModel behind it. Driven by alwaysOnNotificationServiceManager below,
// which already tracks which accounts opted in.
val accountSubscriptions = AccountSubscriptionRegistry(sources.account)
// Manages always-on notification service lifecycle. Preloads every saved
// writable account while enabled so GiftWraps for non-active accounts still
// get unwrapped by their owning account's newNotesPreProcessor.
@@ -928,6 +985,8 @@ class AppModules(
scope = applicationIOScope,
accountsCache = accountsCache,
localPreferences = LocalPreferences,
subscriptions = accountSubscriptions,
isForeground = foregroundTracker.isForeground,
activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey },
)
@@ -1019,6 +1078,8 @@ class AppModules(
)
}
// androidx.security.crypto's EncryptedSharedPreferences is deprecated with no drop-in successor.
@Suppress("DEPRECATION")
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub)
fun initiate(appContext: Context) {
@@ -1077,9 +1138,11 @@ class AppModules(
uiState
}
// LRUCache should not be instanciated in the Main thread due to blocking
// LRUCache should not be instanciated in the Main thread due to blocking.
// trimToSize is a no-op on the empty cache; it just forces the object's
// (blocking) initialization to happen off the main thread.
applicationIOScope.launch {
CachedRobohash
CachedRobohash.trimToSize(100)
resourceCacheInit()
}
@@ -1188,6 +1251,14 @@ class AppModules(
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
localBlossomCacheProbe.invalidate()
// Re-probe immediately so enabling the feature activates it
// this session. Otherwise `available` only advances when a
// `blossom:` URI is resolved, and the common feed-image path
// never routes through the resolver until `available` is
// already true — so a freshly-enabled toggle (or a cache that
// came up after launch) would stay dormant and the settings
// "detected" chip would read stale.
localBlossomCacheProbe.isAvailable()
}
}
}
@@ -31,6 +31,9 @@ class EncryptedStorage {
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
// androidx.security.crypto is deprecated with no drop-in successor; migrating the
// on-disk key store is a separate, security-sensitive effort.
@Suppress("DEPRECATION")
fun preferences(
applicationContext: Context,
npub: String? = null,
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -70,7 +71,9 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -130,6 +133,7 @@ private object PrefKeys {
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
const val DEFAULT_HIGHLIGHTS_FOLLOW_LIST = "defaultHighlightsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
@@ -187,6 +191,10 @@ private object PrefKeys {
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
// added type defaults enabled for accounts that customized before it existed.
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
// Same convention as DISABLED_CHAT_FEEDS but for the Home feed's event-kind groups: stores the
// DISABLED codes so absence = all-on and any newly added group defaults enabled.
const val DISABLED_HOME_FEED_TYPES = "disabled_home_feed_types"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
@@ -205,12 +213,14 @@ private object PrefKeys {
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids"
const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites"
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
const val PENDING_ATTESTATIONS = "pending_attestations"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
const val LATEST_BOLT12_OFFERS = "latestBolt12Offers"
const val LATEST_CASHU_WALLET = "latestCashuWallet"
const val LATEST_NUTZAP_INFO = "latestNutzapInfo"
}
@@ -316,6 +326,9 @@ object LocalPreferences {
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
// How many accounts are in play is the first thing you need when reading any
// boot log: nearly every per-account subsystem below multiplies by this number.
Log.i("LocalPreferences") { "Found ${newSystemOfAccounts.size} saved account(s)" }
newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
@@ -514,6 +527,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
@@ -588,6 +602,7 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_BOLT12_OFFERS, settings.backupBolt12Offers)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
@@ -600,6 +615,7 @@ object LocalPreferences {
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value))
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
@@ -627,6 +643,7 @@ object LocalPreferences {
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value)
putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value)
putString(
PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS,
JsonMapper.toJson(settings.viewedPollResultNoteIds.value),
@@ -699,6 +716,7 @@ object LocalPreferences {
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
Log.d("LocalPreferences") { "Load account from file $npub" }
val startedAtMs = TimeUtils.nowMillis()
val result =
withContext(Dispatchers.IO) {
return@withContext with(encryptedPreferences(npub)) {
@@ -726,21 +744,15 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
// Read as a group via a helper: this load lambda sits right at the JVM's
// per-method bytecode limit (see the note above the awaits below), so keeping
// these heavy string/enum decodes out of it preserves headroom.
val inboxPrefs = readInboxPrefs()
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
@@ -777,6 +789,7 @@ object LocalPreferences {
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestBolt12OffersStr = getString(PrefKeys.LATEST_BOLT12_OFFERS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null)
@@ -840,6 +853,7 @@ object LocalPreferences {
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestBolt12Offers = async { parseEventOrNull<Bolt12OfferListEvent>(latestBolt12OffersStr) }
val latestCashuWallet =
async {
parseEventOrNull<com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent>(latestCashuWalletStr)
@@ -895,6 +909,7 @@ object LocalPreferences {
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestBolt12OffersResolved = latestBolt12Offers.await()
val latestCashuWalletResolved = latestCashuWallet.await()
val latestNutzapInfoResolved = latestNutzapInfo.await()
@@ -927,6 +942,7 @@ object LocalPreferences {
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
defaultHighlightsFollowList = MutableStateFlow(followListPrefs.highlights),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
@@ -959,14 +975,15 @@ object LocalPreferences {
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
concordViewMode = MutableStateFlow(concordViewMode),
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
defaultRelayAuthPolicy = MutableStateFlow(inboxPrefs.defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(inboxPrefs.relayGroupViewMode),
concordViewMode = MutableStateFlow(inboxPrefs.concordViewMode),
enabledChatFeeds = MutableStateFlow(inboxPrefs.enabledChatFeeds),
enabledHomeFeedTypes = MutableStateFlow(inboxPrefs.enabledHomeFeedTypes),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(inboxPrefs.relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(inboxPrefs.relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(inboxPrefs.relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(inboxPrefs.relayAuthTrustMessageStrangers),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -994,16 +1011,22 @@ object LocalPreferences {
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
backupBolt12Offers = latestBolt12OffersResolved,
backupCashuWallet = latestCashuWalletResolved,
backupNutzapInfo = latestNutzapInfoResolved,
callsEnabled = MutableStateFlow(callsEnabled),
)
}
}
Log.d("LocalPreferences") { "Loaded account from file $npub" }
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
// the most expensive things a cold start does (it resolves a fan of backup events), it runs
// once per account, and "which account was slow" is the first question when a boot drags.
// The six intermediate steps above stay at DEBUG.
Log.i("LocalPreferences") { "Loaded account $npub in ${TimeUtils.nowMillis() - startedAtMs}ms" }
return result
}
@@ -1033,6 +1056,7 @@ object LocalPreferences {
val nsites: TopFilter,
val workouts: TopFilter,
val gitRepositories: TopFilter,
val highlights: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
@@ -1090,6 +1114,7 @@ object LocalPreferences {
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -1173,3 +1198,36 @@ object LocalPreferences {
}
}
}
/**
* The inbox / relay-auth / feed-type preferences, read as one group. Extracted out of
* [LocalPreferences]' account-load lambda (which is right at the JVM's per-method bytecode limit)
* so these enum/set decodes don't count against that method's budget.
*/
private class InboxPrefs(
val defaultRelayAuthPolicy: RelayAuthPolicy,
val relayGroupViewMode: RelayGroupViewMode,
val concordViewMode: ConcordViewMode,
val enabledChatFeeds: Set<ChatFeedType>,
val enabledHomeFeedTypes: Set<HomeFeedType>,
val relayAuthTrustMyRelays: Boolean,
val relayAuthTrustReadFollows: Boolean,
val relayAuthTrustMessageFollows: Boolean,
val relayAuthTrustMessageStrangers: Boolean,
)
private fun SharedPreferences.readInboxPrefs() =
InboxPrefs(
defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM,
relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)),
concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)),
enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)),
enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)),
relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true),
relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true),
relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true),
relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false),
)
@@ -24,8 +24,10 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
@@ -34,6 +36,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
@@ -46,8 +50,10 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
@@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
@@ -97,6 +104,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
@@ -152,7 +160,30 @@ import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryT
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
@@ -207,8 +238,12 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -217,11 +252,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNu
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
@@ -255,12 +293,14 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip22Comments.notify
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
@@ -268,12 +308,18 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -325,6 +371,7 @@ import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.withoutClientTag
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
@@ -343,6 +390,9 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -363,6 +413,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
@@ -389,7 +441,6 @@ class Account(
override val signer: NostrSigner,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val otsResolverBuilder: () -> OtsResolver,
@@ -452,7 +503,21 @@ class Account(
// account never surfaces under another (the old cache was a process-wide singleton).
val relayNotifications = NotifyRequestsCache()
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings)
// Shared cache of connected wallets' kind 13194 info events (capabilities +
// encryption + notification support). Backs NIP-44 negotiation in
// NwcSignerState and notification gating in NwcPaymentNotificationWatcher.
val nwcInfoCache =
NwcInfoCache(
fetch = { uri ->
client.fetchFirst(
uri.relayUri,
Filter(kinds = listOf(NwcInfoEvent.KIND), authors = listOf(uri.pubKeyHex), limit = 1),
) as? NwcInfoEvent
},
scope = scope,
)
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings, nwcInfoCache)
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
val localRelayList = LocalRelayListState(signer, cache, scope, settings)
@@ -467,7 +532,10 @@ class Account(
*/
val nip46Signer =
Nip46SignerState(
signer = signer,
// Acting as someone else's bunker: the templates arriving here were composed by the
// connected client, so they are signed exactly as received — our client tag would both
// misattribute the event and change the id the client expects back.
signer = signer.withoutClientTag(),
client = client,
ledger = signerPermissionLedger,
clientStore = nip46ClientStore,
@@ -672,7 +740,6 @@ class Account(
signer = signer,
cache = cache,
scope = scope,
assembler = cashuWalletFilterAssembler(),
outboxRelaysFlow = outboxRelays.flow,
inboxRelaysFlow = notificationRelays.flow,
dmRelaysFlow = dmRelays.flow,
@@ -741,6 +808,8 @@ class Account(
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
val bolt12OfferList = Bolt12OfferListState(signer, cache, scope, settings)
val feedDecryptionCaches =
FeedDecryptionCaches(
peopleListCache = peopleListDecryptionCache,
@@ -803,6 +872,9 @@ class Account(
val liveGitRepositoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultGitRepositoriesFollowList)
val liveGitRepositoriesFollowListsPerRelay = OutboxLoaderState(liveGitRepositoriesFollowLists, cache, scope).flow
val liveHighlightsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultHighlightsFollowList)
val liveHighlightsFollowListsPerRelay = OutboxLoaderState(liveHighlightsFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
@@ -956,6 +1028,15 @@ class Account(
}
}
/**
* Applies the new bottom-bar list to the reactive synced-settings flow and returns whether it
* changed. Non-suspending and only touches in-memory state, so callers invoke it synchronously on
* the UI thread rapid edits then stay strictly ordered instead of racing on the multi-threaded
* signer dispatcher (an out-of-order write would revert the newer edit, which the settings screen
* re-seeds from this flow). Pair a `true` result with [sendNewAppSpecificData] to publish.
*/
fun applyBottomBarItems(items: List<BottomBarEntry>): Boolean = settings.changeBottomBarItems(items)
suspend fun toggleChatroomPin(room: ChatroomKey) {
settings.toggleChatroomPin(room)
sendNewAppSpecificData()
@@ -1007,7 +1088,7 @@ class Account(
sendNewAppSpecificData()
}
private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData())
internal suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData())
// ---
// NIP-13 proof-of-work publishing
@@ -1394,6 +1475,106 @@ class Account(
client.publish(event, setOf(relay))
}
/**
* True when the default NWC wallet advertises the nwc#2 `pay` method the rail a
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
* info event (its capability advertisement), which [NwcSignerState] already refreshes
* on wallet change. A missing/unfetched info event reads as false, so the zap path
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
*/
fun defaultWalletSupportsBolt12Pay(): Boolean {
val uri = nip47SignerState.defaultWalletUri.value ?: return false
return nip47SignerState.infoCache?.current(uri)?.supportsMethod(NwcMethod.PAY) == true
}
/**
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
*
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
* intent-bound `payer_note`, then only if the wallet returns a payer proof that
* validates builds, self-consumes, and publishes the kind 9736 zap. Validation
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
* fails the binding check, so no invalid receipt is ever published (the payment
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
* external-wallet or LNURL fallback because only NWC returns the proof.
*/
suspend fun sendBolt12Zap(
zappedEvent: Event?,
recipientPubKey: HexKey,
offer: String,
amountMillisats: Long,
message: String,
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> Unit,
onProcessed: () -> Unit,
) {
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
if (zapType == LnZapEvent.ZapType.NONZAP) {
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
scope.launch {
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
onProcessed()
}
}
return
}
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else signer
val intent =
if (zappedEvent == null) {
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
} else {
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
}
val payerNote = Bolt12ZapBuilder.payerNote(intent)
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
scope.launch {
// try/finally so a failure while assembling/publishing the receipt (e.g. a
// remote signer error) still steps progress and surfaces an error, instead
// of vanishing as an uncaught coroutine exception. The payment already
// settled at this point, so such a failure means "paid, no receipt".
try {
when (response) {
is PaySuccessResponse -> {
val proof = response.result?.payer_proof
if (proof.isNullOrBlank()) {
onError(R.string.bolt12_zap_paid_no_receipt, null)
} else {
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
if (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
cache.justConsumeMyOwnEvent(zap)
client.publish(zap, computeRelayListToBroadcast(zap))
} else {
onError(R.string.bolt12_zap_invalid_receipt, null)
}
}
}
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
onError(R.string.bolt12_zap_paid_no_receipt, null)
} finally {
onProcessed()
}
}
}
}
suspend fun createZapRequestFor(
user: User,
message: String = "",
@@ -2185,6 +2366,7 @@ class Account(
text: String,
replyTo: Note? = null,
replyMode: ReplyMode = ReplyMode.INLINE,
imetas: List<IMetaTag> = emptyList(),
): Boolean {
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
@@ -2198,8 +2380,11 @@ class Account(
val parent = replyTo?.event
val wrap =
when {
// A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9
// message quoting the parent; a fresh post is a plain kind-9 message.
// A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when
// the user attached media); an inline reply is a kind-9 message quoting the parent; a
// fresh post is a plain kind-9 message.
parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() ->
ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags)
parent != null && replyMode == ReplyMode.MINICHAT ->
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
parent != null ->
@@ -2246,6 +2431,7 @@ class Account(
suspend fun sendMinichatReply(
rootNote: Note,
text: String,
imetas: List<IMetaTag> = emptyList(),
): Boolean {
if (!isWriteable()) return false
val gatherers = rootNote.inGatherers
@@ -2257,16 +2443,33 @@ class Account(
text,
rootNote,
ReplyMode.MINICHAT,
imetas,
)
}
// Public chats: a plain public kind-1111 comment rooted at the message. NIP-29 groups
// additionally carry the `h` tag and go only to the host relay.
// additionally carry the `h` tag and go only to the host relay. Attached media rides as
// NIP-92 `imeta` tags, with each URL appended to the content so any client renders it.
val rootEvent = rootNote.event ?: return false
// Resolve @/nostr: mentions the same way the full composer does, so a member cited in a
// quick reply is notified (`p`) and their reference resolves. The reply-parent author is
// already tagged by each builder below, so drop it from the body mentions to avoid a
// duplicate `p`.
val tagger = NewMessageTagger(text, dao = LocalCache)
tagger.run()
val mentions = tagger.pTags?.mapNotNull { it.pubkeyHex.takeIf { pk -> pk != rootEvent.pubKey } }.orEmpty()
val finalText = appendMediaUrls(tagger.message, imetas)
gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat ->
val relays = chat.relays()
val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull())))
val signed =
signer.sign(
CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, relays.firstOrNull())) {
notify(mentions.map { PTag(it) })
imetas(imetas)
},
)
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, relays.ifEmpty { outboxRelays.flow.value })
return true
@@ -2275,12 +2478,41 @@ class Account(
gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.let { group ->
val hostRelay = group.groupId.relayUrl
val signed =
signer.sign(
CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) {
hTag(group.groupId.id)
previous(group.previousEventRefs(pubKey))
},
)
if (BuzzRelayDialect.isBuzz(hostRelay)) {
// Buzz rejects kind-1111, so its minichat threads with a NIP-10 `reply`-marked `e`
// on a plain kind-9 chat — byte-identical to `_buildReplyTags` in Buzz's own client
// (direct reply -> one `reply` marker; nested -> `root` + `reply`), which is what
// [buzzThread] emits.
//
// This used to write kind-40002. Nothing in Buzz writes 40002 any more — every send
// path in their mobile, desktop and CLI clients emits kind 9, and their NOSTR.md
// grades 40002 "Buzz-only — no standard NIP-29 client renders these" against kind 9's
// blessed status. 40002 survives only as a read-compat tail from the
// 10002 -> 40001 -> 40002 migration, so we were the last active writer of a kind
// their clients no longer thread on. Reading 40002 stays supported (see
// [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply]).
//
// Attached media rides as URLs appended to the content.
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
signer.sign(
ChatEvent.build(finalText) {
hTag(group.groupId.id)
buzzThread(root, rootEvent.id)
rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) }
pTags(mentions.map { PTag(it) })
previous(group.previousEventRefs(pubKey))
},
)
} else {
signer.sign(
CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, hostRelay)) {
hTag(group.groupId.id)
previous(group.previousEventRefs(pubKey))
notify(mentions.map { PTag(it) })
imetas(imetas)
},
)
}
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(hostRelay))
return true
@@ -2289,6 +2521,21 @@ class Account(
return false
}
/**
* Appends each attachment URL not already present in [text] to the message content (newline
* separated), so a plaintext media link renders inline in any client mirroring
* [com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat.imageMessage]. Returns [text]
* unchanged when there are no attachments.
*/
private fun appendMediaUrls(
text: String,
imetas: List<IMetaTag>,
): String {
if (imetas.isEmpty()) return text
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
}
/**
* React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors
* [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's
@@ -2317,6 +2564,37 @@ class Account(
return true
}
/**
* Edit my own Concord channel message [note] to [newText]. Mirrors
* [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the
* message's channel/epoch, wraps it on the plane, and publishes it so the edit stays
* inside the encrypted channel (a public edit would e-tag the private rumor id onto
* public relays). The receiving side overlays the newest edit onto the target message;
* only the *original author's* edits are applied, so we gate to my own kind-9 messages.
* Returns false if [note] isn't an editable Concord message I authored.
*/
suspend fun editConcordChannelMessage(
note: Note,
newText: String,
): Boolean {
if (!isWriteable()) return false
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false
val target = note.event ?: return false
// Edits only apply to plain kind-9 messages, and only the author may edit their own.
if (target !is ChatEvent || target.pubKey != signer.pubKey) return false
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message.
val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
/**
* Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel call at
* most every few seconds while composing. Not folded locally (we never show our own typing);
@@ -2869,10 +3147,15 @@ class Account(
* Read-only import: kind 13302 is replaceable, so folding an older copy is a
* no-op and this is safe to call on every hub open. Merging our own edits with
* a foreign writer's is a separate concern (newest-wins replaceable).
*
* [extraRelays] are additional relays to query the bootstrap relays saved on the
* bottom-bar tabs of pinned communities. A community's private list frequently lives
* only on the community's own relays (never the user's outbox), so a community pinned
* to the bottom bar would otherwise never surface when opened cold.
*/
suspend fun importConcordCommunities() {
suspend fun importConcordCommunities(extraRelays: Set<NormalizedRelayUrl> = emptySet()) {
val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value).toSet()
val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value + extraRelays).toSet()
if (relays.isEmpty()) return
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey))
// Stock relays like relay.ditto.pub can be slow (~1020s to first response), so give
@@ -2888,6 +3171,76 @@ class Account(
newest?.let { cache.justConsumeMyOwnEvent(it) }
}
/**
* One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox
* fill in without the user opening each channel one by one. Per channel, a channel read before is
* caught up from its last-read time (accurate unread badge + the missed messages ready when it
* opens) while a channel never read pulls only its single newest wrap for a preview see
* [ConcordSubscriptionPlanner.channelPreviewFilters].
*
* This is deliberately **not** a live subscription: every wrap the drain pulls flows through the
* global cache connector (`CacheClientConnector` `LocalCache.justConsume` `concordSessions.ingest`),
* so it lands in the channel's message store the previews/unread counts read and the always-on
* plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward.
* So this only needs to run when a community's channels first fold (the account preload) or its
* screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay.
*/
suspend fun warmConcordChannelPreviews(entries: List<ConcordCommunityListEntry>) {
val filters =
entries.flatMap { entry ->
val state = concordSessions.sessionFor(entry.id)?.state?.value ?: return@flatMap emptyList()
ConcordSubscriptionPlanner.channelPreviewFilters(
entry,
state,
lastReadFor = { channelIdHex ->
loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex))
},
accountPubKey = userProfile().pubkeyHex,
)
}
if (filters.isEmpty()) return
val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } }
client.fetchAll(filters = byRelay, timeoutMs = 20_000L)
}
/**
* COMPLETE-mode Control-Plane sync Armada's plane-sweep discipline for the one plane that must
* never fold on a truncated edition set.
*
* The Control Plane defines the channel list, the roster and the banlist, so a *partial* fold
* silently drops channels or mis-renders membership. Two ways that happens, both closed here:
* - **Forward-cursor gap:** the live plane subscription advances a `since` cursor, so an edition
* with a `created_at` below the high-water mark that we never actually ingested an unban
* published while we were offline, a CORD-06 compaction re-wrap under a newly-held epoch is
* never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the
* whole plane every run.
* - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can
* crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until`
* cursors until a plane is drained), so the fold sees every edition regardless of the cap.
*
* Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the
* priors). Wraps ingest through the global cache connector [concordSessions] like every other
* Concord drain; AUTH is the shared stream-key handler. Merging communities that share a relay into
* one filter is safe here precisely because we page the cap no longer truncates. The live control
* subscription still carries brand-new editions in real time; this is the periodic completeness pass.
*/
suspend fun syncConcordControlPlanes(entries: List<ConcordCommunityListEntry>) {
if (entries.isEmpty()) return
val authorsByRelay = HashMap<NormalizedRelayUrl, MutableSet<String>>()
for (entry in entries) {
for (sub in ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))) {
for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex)
}
}
if (authorsByRelay.isEmpty()) return
// No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a
// plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap.
val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) }
var drained = 0
client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ }
Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)")
}
// ── NIP-29 relay-group actions ───────────────────────────────────────────
// All group commands are published ONLY to the group's host relay, where
// relay29 authorizes them. The relay is the source of truth; the kind-10009
@@ -2903,6 +3256,202 @@ class Account(
follow(channel)
}
/**
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
* (never stored) and fire-and-forget no delivery tracking, no local echo (we filter
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
*/
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
if (!isWriteable()) return
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
client.publish(signed, setOf(channel.groupId.relayUrl))
}
/**
* Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010
* command. [participants] are the OTHER 1-8 people the relay adds me, derives the
* canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent]
* (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry].
* We never assign the channel id ourselves, so callers discover the materialized DM
* by watching that registry rather than from this call's return.
*/
suspend fun openBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
): String? {
val signed = signer.sign(DmOpenEvent.build(participants))
// The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` —
// the authoritative, relay-assigned channel UUID (the deployed relay does not emit a
// queryable kind-41001). Read it straight from the ack so the caller can open the chat.
var results = client.publishAndCollectResults(signed, setOf(relay))
var channelId = buzzDmChannelIdFromAck(results)
// NIP-42 write race: on a cold connection the relay rejects the first publish with
// `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm
// the connection with a pendingOnAuthRequired read so the auth coordinator completes the
// handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix.
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
results = client.publishAndCollectResults(signed, setOf(relay))
channelId = buzzDmChannelIdFromAck(results)
}
return channelId
}
/** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */
private fun buzzDmChannelIdFromAck(results: Map<NormalizedRelayUrl, PublishResult>): String? =
results.values
.firstOrNull { it.accepted }
?.message
?.substringAfter("\"channel_id\":\"", "")
?.substringBefore('"')
?.takeIf { it.isNotBlank() }
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
val template = DmHideEvent.build(channel.groupId.id)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */
suspend fun addBuzzDmMember(
channel: RelayGroupChannel,
member: HexKey,
) {
val template = DmAddMemberEvent.build(channel.groupId.id, member)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* File a Buzz agent job (kind-43001) into channel [channelId] on [relay] a shared
* feature-request the workspace bot can pick up. Untargeted: any agent watching the
* channel may accept it. Returns the new job id (the request event id), or null when the
* account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator].
*/
suspend fun fileBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
request: String,
): HexKey? {
if (!isWriteable()) return null
val signed = signer.sign(JobRequestEvent.build(request, channelId, null))
// Reflect it locally so the board updates immediately (publish only sends to relays).
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(relay))
return signed.id
}
/** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */
suspend fun cancelBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
jobId: HexKey,
) {
if (!isWriteable()) return
val signed = signer.sign(JobCancelEvent.build(jobId, "", channelId))
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(relay))
}
/**
* Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on
* [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the
* approval token), returned here. A run pauses on a human-approval gate before anything ships
* see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator].
*/
suspend fun triggerBuzzWorkflow(
relay: NormalizedRelayUrl,
channelId: String,
workflowId: String,
task: String,
): HexKey? {
if (!isWriteable()) return null
val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId))
val signed = signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) })
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(relay))
return signed.id
}
/**
* Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an
* addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a
* human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
*/
suspend fun publishBuzzWorkflowDef(
relay: NormalizedRelayUrl,
channelId: String,
name: String,
yaml: String,
): String? {
if (!isWriteable()) return null
val workflowId = RandomInstance.randomChars(16)
val signed = signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(relay))
return workflowId
}
/**
* Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which
* doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work.
* Publishing to the single group [relay]; the runner discovers the decision by author.
*/
suspend fun approveBuzzWorkflowRun(
relay: NormalizedRelayUrl,
runId: HexKey,
note: String = "",
): HexKey? {
if (!isWriteable()) return null
val signed = signer.sign(ApprovalGrantEvent.build(runId, note))
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(relay))
return signed.id
}
/** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */
suspend fun denyBuzzWorkflowRun(
relay: NormalizedRelayUrl,
runId: HexKey,
note: String = "",
): HexKey? {
if (!isWriteable()) return null
val signed = signer.sign(ApprovalDenyEvent.build(runId, note))
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(relay))
return signed.id
}
/**
* Upvote a Buzz job [jobId] (authored by [jobAuthor]) a NIP-25 like (kind-7 `+`) `e`-tagging
* the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and
* `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority.
*/
suspend fun upvoteBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
jobId: HexKey,
jobAuthor: HexKey?,
) {
if (!isWriteable()) return
val template =
eventTemplate<ReactionEvent>(ReactionEvent.KIND, ReactionEvent.LIKE) {
addUnique(ETag.assemble(jobId, null, null))
jobAuthor?.let { addUnique(PTag.assemble(it, null)) }
addUnique(arrayOf("k", JobRequestEvent.KIND.toString()))
addUnique(GroupIdTag.assemble(channelId))
}
val signed = signer.sign(template)
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(relay))
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)
@@ -2910,6 +3459,22 @@ class Account(
unfollow(channel)
}
/**
* Delete the whole group with a kind 9008 delete-group event (owner/admin only the relay
* enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than
* just removing me; the relay drops the group and its messages. Also drops it from our own list
* so it disappears from Messages immediately instead of lingering as a now-dead id.
*/
suspend fun deleteRelayGroup(channel: RelayGroupChannel) {
val template = DeleteGroupEvent.build(channel.groupId.id)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
unfollow(channel)
// Remember the deletion so the channel leaves the community's browse list immediately and
// stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a
// stale re-announced 44100 on a Buzz relay) would otherwise keep it visible.
RelayGroupDeletions.markDeleted(channel.groupId)
}
/**
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
@@ -2928,8 +3493,21 @@ class Account(
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = null,
channelType: String? = null,
): GroupId {
signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) }
// The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes
// its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without
// a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay
// publish two events and produce nothing at all.
signAndSendPrivatelyOrBroadcast(
CreateGroupEvent.build(
groupId = groupId,
name = name,
about = about,
visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN,
channelType = channelType,
),
) { listOf(relay) }
val edit =
EditMetadataEvent.build(
@@ -3039,10 +3617,44 @@ class Account(
pubkey: HexKey,
roles: List<String>,
) {
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles))
// Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its
// own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
val buzzRole =
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
when {
roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN
else -> BUZZ_ROLE_MEMBER
}
} else {
null
}
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the
* relay-admin add-member command (kind 9030). Owner/admin only the relay validates the
* sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to
* [relay] with no channel scope.
*/
suspend fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) {
signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) }
}
/** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */
suspend fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) {
signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
}
/**
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
*
@@ -3067,6 +3679,10 @@ class Account(
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
) {
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
val template =
EditMetadataEvent.build(
channel.groupId.id,
@@ -3078,10 +3694,24 @@ class Account(
geohashes = geohashes,
parent = parent,
children = children,
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
* history the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
*/
suspend fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) {
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community))
suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community))
@@ -3518,6 +4148,7 @@ class Account(
alt: String?,
contentWarningReason: String?,
originalHash: String? = null,
videoKind: VideoPostKind = VideoPostKind.AUTO,
) {
if (!isWriteable()) return
@@ -3561,7 +4192,10 @@ class Account(
thumbhash = headerInfo.thumbHash?.thumbhash,
)
if (headerInfo.dim.height > headerInfo.dim.width) {
// The composer forces the kind when it was opened from a feed that only reads one of
// them (Shorts, Longs) or from that feed's share target, so the post lands where the
// user asked for it. Everywhere else the orientation decides.
if (videoKind.isShort(headerInfo.dim)) {
VideoShortEvent.build(videoMeta, alt ?: "") {
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
@@ -5412,6 +6046,8 @@ class Account(
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
suspend fun saveBolt12Offers(offers: List<String>) = sendMyPublicAndPrivateOutbox(bolt12OfferList.saveOffers(offers))
fun markAsRead(
route: String,
timestampInSecs: Long,
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -75,6 +76,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -249,6 +251,7 @@ class AccountSettings(
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultHighlightsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -319,9 +322,16 @@ class AccountSettings(
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
/**
* Channel ids the viewer chose NOT to show on Messages after somebody added them to the channel
* (kind-44100). Local-only: it records a display preference, not membership — the relay roster
* still lists you, and Leave (kind 9022) is the separate action that actually removes you.
*/
val dismissedChannelInvites: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
var backupBolt12Offers: Bolt12OfferListEvent? = null,
var callTurnServers: List<CallTurnServer> = emptyList(),
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
@@ -332,6 +342,9 @@ class AccountSettings(
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
// Which event-kind groups the Home feed downloads (assembler) and renders (DAL). A disabled group
// is both dropped from the always-on home relay filters and hidden from the tabs. Everything on by default.
val enabledHomeFeedTypes: MutableStateFlow<Set<HomeFeedType>> = MutableStateFlow(HomeFeedType.ALL),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
@@ -382,6 +395,20 @@ class AccountSettings(
}
}
fun isHomeFeedTypeEnabled(type: HomeFeedType): Boolean = type in enabledHomeFeedTypes.value
fun setHomeFeedTypeEnabled(
type: HomeFeedType,
enabled: Boolean,
) {
val current = enabledHomeFeedTypes.value
val next = if (enabled) current + type else current - type
if (next != current) {
enabledHomeFeedTypes.tryEmit(next)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -465,6 +492,15 @@ class AccountSettings(
return false
}
fun changeBottomBarItems(newItems: List<BottomBarEntry>): Boolean {
if (syncedSettings.navigation.bottomBarItems.value != newItems) {
syncedSettings.navigation.bottomBarItems.tryEmit(newItems)
saveAccountSettings()
return true
}
return false
}
/** The selected default spend rail across both NWC wallets and CLINK debits. */
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
@@ -860,6 +896,17 @@ class AccountSettings(
}
}
fun changeDefaultHighlightsFollowList(name: FeedDefinition) {
changeDefaultHighlightsFollowList(name.code)
}
fun changeDefaultHighlightsFollowList(name: TopFilter) {
if (defaultHighlightsFollowList.value != name) {
defaultHighlightsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
@@ -1260,6 +1307,16 @@ class AccountSettings(
}
}
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupBolt12Offers?.id != newBolt12Offers.id) {
backupBolt12Offers = newBolt12Offers
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
@@ -1509,6 +1566,27 @@ class AccountSettings(
}
}
// ---
// dismissed channel invites (somebody added me to a channel; I don't want it on Messages)
// ---
fun isDismissedChannelInvite(channelId: String) = dismissedChannelInvites.value.contains(channelId)
fun dismissChannelInvite(channelId: String) {
if (!dismissedChannelInvites.value.contains(channelId)) {
dismissedChannelInvites.update { it + channelId }
saveAccountSettings()
}
}
/** Undo a dismissal — used when the viewer accepts the channel after all, so it can re-prompt later. */
fun undismissChannelInvite(channelId: String) {
if (dismissedChannelInvites.value.contains(channelId)) {
dismissedChannelInvites.update { it - channelId }
saveAccountSettings()
}
}
// ---
// pinned chatrooms
// ---
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -79,6 +80,10 @@ class AccountSyncedSettings(
MutableStateFlow(internalSettings.proofOfWork.difficulty),
MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)),
)
val navigation =
AccountNavigationPreferences(
MutableStateFlow(internalSettings.navigation.bottomBarItems),
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
@@ -119,6 +124,7 @@ class AccountSyncedSettings(
.map { it.id }
.sorted(),
),
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -210,6 +216,11 @@ class AccountSyncedSettings(
if (proofOfWork.enabledCategories.value != newPoWCategories) {
proofOfWork.enabledCategories.tryEmit(newPoWCategories)
}
val newBottomBarItems = syncedSettingsInternal.navigation.bottomBarItems
if (navigation.bottomBarItems.value != newBottomBarItems) {
navigation.bottomBarItems.tryEmit(newBottomBarItems)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -308,6 +319,11 @@ class AccountMediaPreferences(
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
)
@Stable
class AccountNavigationPreferences(
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
)
@Stable
class AccountChatPreferences(
val pinnedChatrooms: MutableStateFlow<Set<ChatroomKey>>,
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.serialization.Serializable
import java.util.Locale
@@ -159,6 +161,15 @@ class AccountSyncedSettingsInternal(
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(),
val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(),
val navigation: AccountNavigationPreferencesInternal = AccountNavigationPreferencesInternal(),
)
@Serializable
class AccountNavigationPreferencesInternal(
// The ordered list of tabs pinned to the bottom navigation bar (built-ins,
// favorite apps, and individual joined chats/groups). Defaulted so blobs
// written before this field existed decode to the app's current defaults.
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
)
@Serializable
@@ -89,7 +89,13 @@ class AntiSpamFilter {
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
// it is already reported where it can be acted on — relayStats.newSpam below and
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
// with a pair of njump links each, which is the widest line in the log and says
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
// open the two events and compare them.
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -118,7 +124,13 @@ class AntiSpamFilter {
// LRU cache while the spammer record still matches this hash.
val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
// it is already reported where it can be acted on — relayStats.newSpam below and
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
// with a pair of njump links each, which is the widest line in the log and says
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
// open the two events and compare them.
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
/**
* The distinct event-kind groups the Home feed downloads (in the relay assembler) and renders (in
* the DAL). Each is independently toggleable in Settings Home: turning one off both drops its
* kinds from the always-on home relay filters AND hides them from the New Threads / Conversations /
* Everything tabs.
*
* [code] is the stable on-disk identifier (do NOT rename — it is what [encode]/[decode] persist);
* the enum ordinal is never stored, so entries may be reordered freely. [kinds] are the Nostr event
* kinds this group governs; they must stay disjoint across entries so a single toggle owns each kind.
*/
enum class HomeFeedType(
val code: String,
val kinds: List<Int>,
) {
TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)),
REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)),
COMMENTS("comments", listOf(CommentEvent.KIND)),
PICTURES("pictures", listOf(PictureEvent.KIND)),
VIDEOS("videos", listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND)),
SHORTS("shorts", listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND)),
ARTICLES("articles", listOf(LongTextNoteEvent.KIND)),
WIKI("wiki", listOf(WikiNoteEvent.KIND)),
HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)),
POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)),
CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)),
TORRENTS("torrents", listOf(TorrentEvent.KIND)),
VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)),
LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)),
EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)),
INTERACTIVE_STORIES("interactive_stories", listOf(InteractiveStoryPrologueEvent.KIND)),
CHESS("chess", listOf(ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND)),
BIRDS("birds", listOf(BirdDetectionEvent.KIND, BirdexEvent.KIND)),
ATTESTATIONS(
"attestations",
listOf(
AttestationEvent.KIND,
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
AttestorProficiencyEvent.KIND,
),
),
NIPS("nips", listOf(NipTextEvent.KIND)),
MUSIC("music", listOf(AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, AudioHeaderEvent.KIND)),
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
;
companion object {
/** Every group, enabled by default so a fresh (or never-customized) account loads everything. */
val ALL: Set<HomeFeedType> = entries.toSet()
fun fromCode(code: String?): HomeFeedType? = entries.firstOrNull { it.code == code }
/** Serializes a set of groups as their comma-joined [code]s, for SharedPreferences. */
fun encode(types: Set<HomeFeedType>): String = types.joinToString(",") { it.code }
/** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */
fun decode(joined: String?): Set<HomeFeedType> =
joined
?.split(",")
?.mapNotNull { fromCode(it.trim()) }
?.toSet()
?: emptySet()
/**
* The event kinds to drop from the home relay filters and the home DAL, given the currently
* [enabled] set. A kind stays live if ANY enabled group still owns it (guards against a
* future overlap between two groups), so disabling one group never silently hides a kind a
* still-enabled group also wants.
*/
fun disabledKinds(enabled: Set<HomeFeedType>): Set<Int> {
if (enabled.size == ALL.size) return emptySet()
val enabledKinds = enabled.flatMapTo(HashSet()) { it.kinds }
return (ALL - enabled).flatMapTo(HashSet()) { it.kinds }.apply { removeAll(enabledKinds) }
}
}
}
@@ -22,12 +22,18 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
@@ -35,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
@@ -49,9 +56,88 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.erReminders.EventReminderEvent
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentitiesListEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.managedAgents.ManagedAgentEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationBanEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
import com.vitorpamplona.quartz.buzz.plPushLease.PushLeaseEvent
import com.vitorpamplona.quartz.buzz.presence.PresenceUpdateEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminChangeRoleEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageBookmarkedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessagePinnedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -115,14 +201,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.VerificationState
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
@@ -197,6 +280,9 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
@@ -309,6 +395,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
@@ -350,7 +440,7 @@ interface ILocalCache {
}
}
object LocalCache : ILocalCache, ICacheProvider {
object LocalCache : ILocalCache, ICacheProvider, Dao {
val antiSpam = AntiSpamFilter()
val users = LargeSoftCache<HexKey, User>()
@@ -385,6 +475,13 @@ object LocalCache : ILocalCache, ICacheProvider {
*/
val onchainZapResolver = OnchainZapResolver(this)
/**
* NIP-B1 BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification
* is synchronous (a self-contained `lnp` payer proof), so `consume(Bolt12ZapEvent)`
* validates inline and needs no async resolver.
*/
val bolt12ZapValidator = Bolt12ZapValidator()
/**
* Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to
* validate NIP-57 Appendix F. `null` skips the receipt-signer check (the
@@ -592,12 +689,12 @@ object LocalCache : ILocalCache, ICacheProvider {
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
override fun getOrCreateUser(hex: HexKey): User {
require(isValidHex(key = hex)) { "$hex is not a valid hex" }
// Pass `this` as the UserContext — User now resolves each pinned
// addressable note (kind:10002 / 10050 / 10019) lazily on first
// read, instead of all-or-nothing at construction time.
return users.getOrCreate(pubkey) { User(it, userContext) }
return users.getOrCreate(hex) { User(it, userContext) }
}
/** [UserContext] bridge to this cache's addressable lookup. */
@@ -644,6 +741,18 @@ object LocalCache : ILocalCache, ICacheProvider {
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
/**
* The [RelayGroupChannel] a group-scoped content [note] belongs to, resolved the same way
* [attachToRelayGroupIfScoped] keyed it: the serving-relay key first (fast O(1)), then the single
* channel bearing this group id when the note has no usable provenance relay. Read-only — used by
* feed filters that need a note's channel without scanning every channel's timeline.
*/
fun getRelayGroupChannelForContent(note: Note): RelayGroupChannel? {
val groupId = note.event?.groupId() ?: return null
note.relays.firstNotNullOfOrNull { getRelayGroupChannelIfExists(GroupId(groupId, it)) }?.let { return it }
return relayGroupChannels.filter { key, _ -> key.id == groupId }.singleOrNull()
}
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
@@ -716,11 +825,11 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun getOrCreateNote(idHex: String): Note {
require(isValidHex(idHex)) { "$idHex is not a valid hex" }
override fun getOrCreateNote(hex: String): Note {
require(isValidHex(hex)) { "$hex is not a valid hex" }
return notes.getOrCreate(idHex) {
Note(idHex)
return notes.getOrCreate(hex) {
Note(hex)
}
}
@@ -838,11 +947,11 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = addressables.getOrCreate(key) { AddressableNote(key) }
override fun getOrCreateAddressableNote(key: Address): AddressableNote {
val note = getOrCreateAddressableNoteInternal(key)
override fun getOrCreateAddressableNote(address: Address): AddressableNote {
val note = getOrCreateAddressableNoteInternal(address)
// Loads the user outside a Syncronized block to avoid blocking
if (note.author == null) {
note.author = checkGetOrCreateUser(key.pubKeyHex)
note.author = checkGetOrCreateUser(address.pubKeyHex)
}
return note
}
@@ -1151,6 +1260,12 @@ object LocalCache : ILocalCache, ICacheProvider {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
is StreamMessageV2Event -> {
// A Buzz thread reply links to the message it answers (its `reply`-marked e-tag) so it
// lands in that message's replies (the minichat). A plain message / non-reply has no marker.
listOfNotNull(event.tags.buzzThreadReply()?.let { checkGetOrCreateNote(it) })
}
is VoiceReplyEvent -> {
event.markedReplyTos().mapNotNull { checkGetOrCreateNote(it) }
}
@@ -1181,6 +1296,17 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
is Bolt12ZapEvent -> {
// NIP-B1 BOLT12 zaps target an event (e), an addressable event (a),
// or just the recipient profile (p) — same shape as onchain zaps.
buildList {
event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } }
event.zappedAddress()?.let { coord ->
Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) }
}
}
}
is NutzapEvent -> {
// The zapped event is carried in the kind:9321's `e` tags
// (and optionally an `a` tag for addressables). Whichever
@@ -1348,12 +1474,17 @@ object LocalCache : ILocalCache, ICacheProvider {
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (version.event?.id == event.id) return false
if (version.event != null) return false
if (wasVerified || justVerify(event)) {
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.flowSet?.ots?.invalidateData()
version.loadEvent(event, author, emptyList())
// Anchor the attestation to the note it timestamps (like an edit to its message), so it
// survives exactly as long as that note and is dropped when the note is deleted or pruned.
// addTimestamp invalidates the target's `ots` flow so the OTS pill re-derives — the old
// code invalidated the attestation's OWN (observer-less) flow, so the target never updated.
event.digestEventId()?.let { targetId ->
getOrCreateNote(targetId).addTimestamp(version)
}
refreshNewNoteObservers(version)
@@ -1742,10 +1873,26 @@ object LocalCache : ILocalCache, ICacheProvider {
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
// Every report that names an author also lands in the naming index, including those
// filed against a note rather than a profile. Read only by the DM sender warning — the
// hide threshold keeps counting `receivedReportsByAuthor` alone.
if (eventsReported.isEmpty()) {
authorsReported.forEach { author -> author.reports().addReport(note) }
authorsReported.forEach { author ->
val reports = author.reports()
reports.addReport(note)
reports.addReportNamingUser(note)
}
} else {
eventsReported.forEach { it.addReport(note) }
// Index only the authors whose own `p` tag carries a report type: an event-scoped
// report can `p`-tag an incidentally-mentioned third party with no type of its own,
// and there is no threshold here to absorb that noise the way
// `receivedReportsByAuthor`'s hide path does.
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey }
authorsReported.forEach { author ->
if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note)
}
}
}
@@ -1986,6 +2133,209 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* Buzz/NIP-43 community (relay-wide) membership snapshot (kind 13534) → the relay's community roster.
* Buzz grants participation across ALL its channels off this list, not the per-channel 39002, so
* feeding it into [BuzzCommunityMembership] lets [RelayGroupChannel.membershipOf] recognise a
* community member even when a channel roster omits them. Scoped to Buzz relays (the concept is
* Buzz-specific and the event is relay-signed + NIP-70 relay-only).
*/
fun consume(
event: RelayMembershipListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && BuzzRelayDialect.isBuzz(relay)) {
if (BuzzCommunityMembership.updateSnapshot(relay, event.members().toSet(), event.createdAt)) {
refreshRelayGroupMembership(relay)
}
}
return new
}
/** Buzz/NIP-43 community member-added delta (kind 8000) → adds to the relay's [BuzzCommunityMembership]. */
fun consume(
event: RelayAddMemberEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBuzzRegularEvent(event, relay, wasVerified)
if (relay != null && BuzzRelayDialect.isBuzz(relay)) {
if (BuzzCommunityMembership.applyDelta(relay, add = event.memberPubKeys().toSet(), createdAt = event.createdAt)) {
refreshRelayGroupMembership(relay)
}
}
return new
}
/** Buzz/NIP-43 community member-removed delta (kind 8001) → removes from the relay's [BuzzCommunityMembership]. */
fun consume(
event: RelayRemoveMemberEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBuzzRegularEvent(event, relay, wasVerified)
if (relay != null && BuzzRelayDialect.isBuzz(relay)) {
if (BuzzCommunityMembership.applyDelta(relay, remove = event.memberPubKeys().toSet(), createdAt = event.createdAt)) {
refreshRelayGroupMembership(relay)
}
}
return new
}
/**
* Poke every relay-group channel on [relay] so the `membershipOf` community-level fallback re-renders
* (community membership lives outside the per-channel roster, so nothing else invalidates their state).
*/
private fun refreshRelayGroupMembership(relay: NormalizedRelayUrl) {
getRelayGroupChannelsOnRelay(relay).forEach { it.updateChannelInfo() }
}
/**
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
* what the composer sends (40002 vs kind 9) and how new channels on the relay are
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
* The note-has-event check is the same verification gate the attach path uses.
*/
private fun markBuzzIfVerified(
event: Event,
relay: NormalizedRelayUrl?,
) {
if (relay != null && getNoteIfExists(event.id)?.event != null) {
BuzzRelayDialect.mark(relay)
}
}
/**
* Consume + channel-attach for Buzz workspace timeline kinds (stream messages,
* diffs, system rows, forum posts, job cards, huddle lifecycle). These kinds only
* exist on `block/buzz` relays, so their (verified) arrival IS the dialect
* detection. Attachment goes through the SAME shared NIP-29 path kind-9 chat uses
* — including its stray-redirect protection — so mixed vanilla/Buzz conversations
* share one timeline and non-host strays never mint phantom channels.
*/
private fun consumeBuzzTimelineEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
attachToRelayGroupIfScoped(event, relay)
}
/**
* A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via
* [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that
* a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery
* events but emits no member-removed notification and never retracts the kind-44100 that seeds the
* browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced
* every restart, its metadata now blank so it shows optimistically). Recording the delete in
* [RelayGroupDeletions] filters it out of every list and persists it across restarts.
*
* Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed
* system message from a stray author can't hide a channel. Cross-device by construction: the relay
* replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too.
*/
private fun consume(
event: SystemMessageEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) {
event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) }
}
}
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
private fun consumeBuzzRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
}
/**
* A Buzz forum ROOT post (kind 45001): a thread, not a chat message. Route it to the group's
* Threads collection ([RelayGroupChannel.addThread]) — the same place kind-11 threads go — so it
* surfaces in the forum/Threads view instead of leaking into the kind-9 chat feed as a bubble.
* Its kind-45003 comments are stored (store-only) and loaded on demand by the thread detail.
*/
private fun consumeBuzzForumPost(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
attachThreadToRelayGroupIfScoped(event, relay)
}
private fun consume(
event: StreamMessageEditEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
// earlier message's content, never a row of its own. Store it and anchor it to the
// message it edits (Note.edits) — like every other edit kind — so the overlay is held
// for as long as its message and never leaks into the timeline as a bubble.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.editedMessage() ?: return@also
val editNote = getOrCreateNote(event.id)
if (editNote.event != null) {
getOrCreateNote(target).addEdit(editNote)
}
}
private fun consume(
event: DmVisibilityEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// The relay-signed, `#p`-gated per-viewer hidden-DM snapshot (30622). Store the
// addressable event AND mirror the viewer's hidden set into the registry so a
// hidden DM drops out of that viewer's list until it's re-opened.
consumeBaseReplaceable(event, relay, wasVerified).also {
val viewer = event.viewer().ifBlank { return@also }
BuzzDmRegistry.recordHidden(viewer, event.hiddenChannels().toSet())
}
private fun consume(
event: CanvasEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// The canvas is the channel's single living document, not a chat row: track
// only the newest revision as overlay state, never attach it to the timeline.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val channelId = event.channel() ?: return@also
val note = getOrCreateNote(event.id)
if (note.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).updateCanvas(note)
}
}
/**
* A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to
* withdraw any pending add-prompt for that channel: once the relay has taken the membership away
* there is nothing left to accept, so leaving the card up would offer an action that cannot succeed.
*/
private fun consume(
event: MemberRemovedNotificationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.target() ?: return@also
val channelId = event.channel() ?: return@also
BuzzChannelInvites.remove(target, channelId)
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
@@ -2380,6 +2730,43 @@ object LocalCache : ILocalCache, ICacheProvider {
return !alreadyLoaded
}
fun consume(
event: Bolt12ZapEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
// Already processed — still route it into any live-activity channel it references.
if (note.event != null) {
attachZapToLiveActivityChannel(event, note, relay)
return false
}
if (!(wasVerified || justVerify(event))) return false
// NIP-B1 validation is fully synchronous: zap-event structure, the embedded
// kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed
// validation drops the zap entirely — it never contributes to a zap total.
// The outer event signature was already verified above (wasVerified/justVerify),
// so skip the redundant re-check inside the validator.
val validation = bolt12ZapValidator.validate(event, verifyEventSignature = false)
if (validation !is Bolt12ZapValidation.Valid) {
Log.w("ZP") { "dropping bolt12 zap ${event.id}: ${(validation as Bolt12ZapValidation.Invalid).reason}" }
return false
}
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
repliesTo.forEach {
it.addBolt12Zap(note, validation.paymentHashHex, validation.amountMillisats, validation.proofCryptoVerified)
}
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
return true
}
/**
* Consume a NIP-61 nutzap (kind 9321). Resolves the e-tagged target
* note(s), parses the proof amounts once, and attaches a `NutzapEntry`
@@ -2435,6 +2822,23 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
private fun attachZapToLiveActivityChannel(
event: Bolt12ZapEvent,
note: Note,
relay: NormalizedRelayUrl?,
) {
// Only surface zaps whose recipient is the live activity host.
val host = event.recipient() ?: return
event.tags
.asSequence()
.mapNotNull(ATag::parseAddress)
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex == host }
.distinct()
.forEach { address ->
getOrCreateLiveChannel(address).addNote(note, relay)
}
}
fun consume(
event: LnZapRequestEvent,
relay: NormalizedRelayUrl?,
@@ -2481,12 +2885,48 @@ object LocalCache : ILocalCache, ICacheProvider {
if (wasVerified || justVerify(event)) {
note.loadEvent(event, author, emptyList())
// Anchor the modification to the note it edits, like every other edit kind — the read side
// (Note.textNoteModifications) then folds `edited.edits` instead of scanning the cache,
// and addEdit invalidates the note's edits flow so the UI re-derives.
event.editedNote()?.let {
checkGetOrCreateNote(it.eventId)?.let { editedNote ->
modificationCache.remove(editedNote.idHex)
// must update list of Notes to quickly update the user.
editedNote.flowSet?.edits?.invalidateData()
}
checkGetOrCreateNote(it.eventId)?.addEdit(note)
}
refreshNewNoteObservers(note)
return true
}
return false
}
fun consume(
event: ConcordChatEditEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
// A Concord edit rumor is unsigned (its sig is empty); the envelope open path already
// established authenticity, so we consume it as pre-verified like any other Concord rumor.
if (wasVerified || justVerify(event)) {
note.loadEvent(event, author, emptyList())
// Anchor the edit to the message it edits (like a reaction to its target), so it survives
// as long as that channel-retained message does. A Concord rumor is decrypted exactly once
// per session — the community session dedups re-delivered wraps — so an edit left orphaned
// in the soft cache could be GC'd and never re-downloaded. The bubble reads `note.edits`.
event.editedMessageId()?.let { targetId ->
getOrCreateNote(targetId).addEdit(note)
}
refreshNewNoteObservers(note)
@@ -2883,73 +3323,6 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getPeopleListNotesFor(user: User): List<AddressableNote> = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex)
suspend fun findEarliestOtsForNote(
note: Note,
otsVerifCacheBuilder: () -> VerificationStateCache,
): Long? {
checkNotInMainThread()
var minTime: Long? = null
val time = TimeUtils.now()
val candidates =
notes.mapNotNull { _, item ->
val noteEvent = item.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
val cachedTime = (otsVerifCacheBuilder().justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime
if (cachedTime != null) {
if (minTime == null || cachedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = cachedTime
}
null
} else {
// tries to verify again
noteEvent
}
} else {
null
}
}
candidates.forEach { noteEvent ->
(otsVerifCacheBuilder().cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime ->
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = stampedTime
}
}
}
return minTime
}
val modificationCache = LruCache<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? = modificationCache[note.idHex]
fun findLatestModificationForNote(note: Note): List<Note> {
checkNotInMainThread()
val noteAuthor = note.author ?: return emptyList()
modificationCache[note.idHex]?.let {
return it
}
val time = TimeUtils.now()
val newNotes =
notes
.filter { _, item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteAuthor == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
modificationCache.put(note.idHex, newNotes)
return newNotes
}
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" }
notes.cleanUp()
@@ -3266,9 +3639,23 @@ object LocalCache : ILocalCache, ICacheProvider {
getNoteIfExists(quotedId)?.removeBoost(note)
}
// Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo`
// back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag
// and drop it there, or a deleted edit would keep overlaying its message.
editedTargetIdOf(noteEvent)?.let { getNoteIfExists(it)?.removeEdit(note) }
// OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with
// no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there.
if (noteEvent is OtsEvent) {
noteEvent.digestEventId()?.let { getNoteIfExists(it)?.removeTimestamp(note) }
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note)
getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
}
noteEvent.reportedPost().forEach {
@@ -3301,6 +3688,15 @@ object LocalCache : ILocalCache, ICacheProvider {
refreshDeletedNoteObservers(note)
}
/** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */
private fun editedTargetIdOf(event: Event?): HexKey? =
when (event) {
is TextNoteModificationEvent -> event.editedNote()?.eventId
is ConcordChatEditEvent -> event.editedMessageId()
is StreamMessageEditEvent -> event.editedMessage()
else -> null
}
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
}
@@ -3881,6 +4277,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Bolt12OfferListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is ClassifiedsEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
@@ -4301,6 +4701,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is Bolt12ZapEvent -> {
consume(event, relay, wasVerified)
}
is NIP90StatusEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -4407,6 +4811,121 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
// ------------------------------------------------------------------
// Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29).
// Timeline kinds attach into the group's BuzzWorkspaceChannel; the
// rest are stored for query/state. Kinds 9041/39005/49001 are absent
// on purpose: their numbers belong to GoalEvent, GroupPinnedEvent and
// a non-wire audit kind. Kind 20001 is shared with GeohashPresenceEvent
// (BitChat); EventFactory routes it to PresenceUpdateEvent only when the
// BitChat `g` tag is absent, and it is handled below with the ephemerals.
// ------------------------------------------------------------------
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consume(event, relay, wasVerified)
is CanvasEvent -> consume(event, relay, wasVerified)
// Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003)
// and votes (45002) are store-only: the forum-thread detail loads them on demand by root.
is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified)
is ForumCommentEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ForumVoteEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
// Buzz addressable/replaceable state.
is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DmVisibilityEvent -> consume(event, relay, wasVerified)
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
// Buzz store-only regular kinds (queryable state; no timeline row yet).
is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberRemovedNotificationEvent -> consume(event, relay, wasVerified)
is RelayMembershipListEvent -> consume(event, relay, wasVerified)
is RelayAddMemberEvent -> consume(event, relay, wasVerified)
is RelayRemoveMemberEvent -> consume(event, relay, wasVerified)
is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz relay-signed sidecars and audit projections: store-only, queryable.
is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
// pollute the note store, and do NOT mark the dialect from them (they are
// never stored, so the verified-mark gate has nothing to check).
is TypingIndicatorEvent -> {
// Live "who is typing" side-effect only — record the heartbeat into the
// process-wide typing state (the channel view collects it) and return
// false so it never becomes a feed row. Own-typing is filtered in the UI.
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
false
}
is PresenceUpdateEvent -> {
// Live online/away/offline side-effect only — record the latest status for
// the subject (the `p` tag on relay-synthesized reads, else the author) into
// the process-wide presence state and return false so it never becomes a row.
BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt)
false
}
is ObserverFrameEvent -> false
is HuddleReactionEvent -> false
// Pairing (24134) is deliberately dialect-neutral: it flows during device
// pairing before any workspace relationship is established.
is PairingEvent -> false
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)
@@ -4495,6 +5014,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is ConcordChatEditEvent -> {
consume(event, relay, wasVerified)
}
is TorrentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import androidx.collection.LruCache
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
interface MutableMediaAspectRatioCache {
fun get(url: String): Float?
@@ -32,10 +34,27 @@ interface MutableMediaAspectRatioCache {
)
}
/**
* Aspect ratios keyed by media URL, learned from imeta `dim` tags up front or from the decoder once
* a first frame lands.
*
* Entries are snapshot state, so a composable that calls [get] **during composition** recomposes
* when the real dimensions arrive later. That matters because players and image loaders only report
* size after the first frame decodes: a caller that sized itself off a plain cache miss would stay
* wrong for the whole visit and only look right the *next* time the media is opened. Note this only
* works for reads made in composition — a read from inside `remember { }` is cached by `remember`
* itself and won't pick the update up.
*/
object MediaAspectRatioCache : MutableMediaAspectRatioCache {
val mediaAspectRatioCacheByUrl = LruCache<String, Float>(1000)
private val cache = LruCache<String, MutableState<Float?>>(1000)
override fun get(url: String): Float? = mediaAspectRatioCacheByUrl.get(url)
// get-then-put has to be atomic, so the compound op is guarded even though LruCache is itself
// thread-safe. A miss still stores a slot: that empty slot is what the caller observes until
// add() fills it in.
@Synchronized
private fun entry(url: String): MutableState<Float?> = cache.get(url) ?: mutableStateOf<Float?>(null).also { cache.put(url, it) }
override fun get(url: String): Float? = entry(url).value
override fun add(
url: String,
@@ -43,7 +62,7 @@ object MediaAspectRatioCache : MutableMediaAspectRatioCache {
height: Int,
) {
if (height > 1) {
mediaAspectRatioCacheByUrl.put(url, width.toFloat() / height.toFloat())
entry(url).value = width.toFloat() / height.toFloat()
}
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/*
* Per-kind resolution of a message's edit overlay from its own `Note.edits`. Every edit that
* targets a note is held there as a hard-referenced child (like a reaction), so these are cheap
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
* note rather than the cache.
*
* All three kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord)
* is trusted to enforce that, so a foreign-authored edit never rewrites your message.
*/
/**
* Every kind-1010 post modification of this note, oldest first (author-only, dropping NIP-40
* expired ones). A list because the post's EditState cycles through the original + each version.
*/
fun Note.textNoteModifications(): List<Note> {
val noteAuthor = author ?: return emptyList()
val now = TimeUtils.now()
return edits
.filter { item ->
val e = item.event
e is TextNoteModificationEvent && noteAuthor == item.author && !e.isExpirationBefore(now)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
}
/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */
fun Note.latestBuzzEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
// idHex tie-break so a same-second pair resolves identically on every client.
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
}
/** The kind-3302 Concord edit overlaying this message, or null — author-only, newest by CORD-02 §4 send time. */
fun Note.latestConcordEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent }
.maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex }))
}
@@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.serialization.Serializable
@Stable
@@ -44,7 +42,6 @@ data class UiSettings(
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
val useTrackedBroadcasts: BooleanType = BooleanType.ALWAYS,
val automaticallyCreateDrafts: BooleanType = BooleanType.ALWAYS,
val bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
val showHomeNewThreadsTab: Boolean = true,
val showHomeConversationsTab: Boolean = true,
val showHomeEverythingTab: Boolean = false,
@@ -21,8 +21,6 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -44,7 +42,6 @@ class UiSettingsFlow(
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val useTrackedBroadcasts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyCreateDrafts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>> = MutableStateFlow(DefaultBottomBarEntries),
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
@@ -77,7 +74,6 @@ class UiSettingsFlow(
automaticallyProposeAiImprovements,
useTrackedBroadcasts,
automaticallyCreateDrafts,
bottomBarItems,
showHomeNewThreadsTab,
showHomeConversationsTab,
showHomeEverythingTab,
@@ -114,7 +110,7 @@ class UiSettingsFlow(
flows[12] as BooleanType,
flows[13] as BooleanType,
flows[14] as BooleanType,
flows[15] as List<BottomBarEntry>,
flows[15] as Boolean,
flows[16] as Boolean,
flows[17] as Boolean,
flows[18] as Boolean,
@@ -122,13 +118,12 @@ class UiSettingsFlow(
flows[20] as Boolean,
flows[21] as Boolean,
flows[22] as Boolean,
flows[23] as Boolean,
flows[24] as BooleanType,
flows[25] as AccentColorType,
flows[26] as FontFamilyType,
flows[27] as FontSizeType,
flows[28] as String,
flows[29] as Boolean,
flows[23] as BooleanType,
flows[24] as AccentColorType,
flows[25] as FontFamilyType,
flows[26] as FontSizeType,
flows[27] as String,
flows[28] as Boolean,
)
}
@@ -149,7 +144,6 @@ class UiSettingsFlow(
automaticallyProposeAiImprovements.value,
useTrackedBroadcasts.value,
automaticallyCreateDrafts.value,
bottomBarItems.value,
showHomeNewThreadsTab.value,
showHomeConversationsTab.value,
showHomeEverythingTab.value,
@@ -229,10 +223,6 @@ class UiSettingsFlow(
automaticallyCreateDrafts.tryEmit(torSettings.automaticallyCreateDrafts)
any = true
}
if (bottomBarItems.value != torSettings.bottomBarItems) {
bottomBarItems.tryEmit(torSettings.bottomBarItems)
any = true
}
if (showHomeNewThreadsTab.value != torSettings.showHomeNewThreadsTab) {
showHomeNewThreadsTab.tryEmit(torSettings.showHomeNewThreadsTab)
any = true
@@ -329,7 +319,6 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.automaticallyProposeAiImprovements),
MutableStateFlow(uiSettings.useTrackedBroadcasts),
MutableStateFlow(uiSettings.automaticallyCreateDrafts),
MutableStateFlow(uiSettings.bottomBarItems),
MutableStateFlow(uiSettings.showHomeNewThreadsTab),
MutableStateFlow(uiSettings.showHomeConversationsTab),
MutableStateFlow(uiSettings.showHomeEverythingTab),
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
/**
* Which NIP-71 kind a video upload is published as. Only videos are affected — whether an upload
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
* overridden.
*
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
* and the Video feed reads both.
*/
enum class VideoPostKind {
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
AUTO,
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
SHORT,
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
NORMAL,
;
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
fun isShort(dim: DimensionTag): Boolean =
when (this) {
SHORT -> true
NORMAL -> false
AUTO -> dim.height > dim.width
}
}
@@ -59,7 +59,6 @@ import java.io.File
class AccountCacheState(
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val contentResolverFn: () -> ContentResolver,
@@ -266,7 +265,6 @@ class AccountCacheState(
signer = signerWithClientTag,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
cashuWalletFilterAssembler = cashuWalletFilterAssembler,
cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler,
okHttpClientForMoney = okHttpClientForMoney,
otsResolverBuilder = otsResolverBuilder,
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.bolt12Offers
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* The logged-in user's NIP-B1 BOLT12 offer list (kind 10058) as live account state,
* mirroring [com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState].
* Exposes the current offers as a [flow], persists them across restarts (via
* [AccountSettings]), and publishes updates with [saveOffers].
*/
class Bolt12OfferListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
val bolt12OfferListNote = cache.getOrCreateAddressableNote(getBolt12OfferListAddress())
fun getBolt12OfferListFlow(): StateFlow<NoteState> = bolt12OfferListNote.flow().metadata.stateFlow
fun getBolt12OfferListAddress() = Bolt12OfferListEvent.createAddress(signer.pubKey)
fun getBolt12OfferListEvent(): Bolt12OfferListEvent? = bolt12OfferListNote.event as? Bolt12OfferListEvent
/** The user's currently-published canonical raw BOLT12 offers. */
val flow: StateFlow<List<String>> =
getBolt12OfferListFlow()
.map { (it.note.event as? Bolt12OfferListEvent)?.offers() ?: emptyList() }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
suspend fun saveOffers(offers: List<String>): Bolt12OfferListEvent {
val existing = getBolt12OfferListEvent()
return if (existing != null && existing.tags.isNotEmpty()) {
Bolt12OfferListEvent.updateOffers(existing, offers, signer)
} else {
Bolt12OfferListEvent.create(offers, signer)
}
}
init {
settings.backupBolt12Offers?.let {
Log.d("AccountRegisterObservers") { "Loading saved BOLT12 offer list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
getBolt12OfferListFlow().collect {
(it.note.event as? Bolt12OfferListEvent)?.let { offerListEvent ->
settings.updateBolt12Offers(offerListEvent)
}
}
}
}
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
class IncomingOtsEventVerifier(
private val otsVerifCache: () -> VerificationStateCache,
private val otsResolverBuilder: OtsResolverBuilder,
private val cache: LocalCache,
private val scope: CoroutineScope,
) {
@@ -49,11 +49,12 @@ class IncomingOtsEventVerifier(
null,
)
// Verifies each newly-arrived attestation once and memoizes the verdict on the note itself
// (Note.otsVerification), so the OTS pill can render off a cached result without re-hitting
// the blockchain. The verdict is evicted with the note — no separate cache to keep in sync.
suspend fun consume(note: Note) {
note.event?.let { event ->
if (event is OtsEvent) {
otsVerifCache().cacheVerify(event)
}
if (note.event is OtsEvent) {
note.cacheVerifyOts(otsResolverBuilder.build())
}
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.VerificationState
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/*
* OTS (NIP-03) attestation resolution off a note's own [Note.timestamps] list. Every kind-1040
* attestation targeting a note is anchored there as a hard-referenced child (like a reaction), so
* finding a note's proofs is an in-memory fold — no LocalCache scan. Each attestation memoizes its
* blockchain verdict in [Note.otsVerification], which shares the attestation note's lifecycle: it is
* evicted when the target note is (a NIP-09 delete or a cache prune), so there is no separate,
* id-keyed verification cache to keep in sync. This is the read side that replaces the old
* VerificationStateCache + full-cache scan.
*/
/** The memoized OTS verdict for this attestation note, or null when it has never been verified. */
fun Note.justOtsVerification(): VerificationState? = otsVerification
/**
* Returns the OTS verdict for this attestation note, verifying against the blockchain only when no
* usable verdict exists yet (or a stale [VerificationState.NetworkError] is due for a retry). The
* result is stored on the note so later reads are free. Must run off the main thread — verification
* hits the network. No-op verdict ([VerificationState.Error]) when the note is not an OTS event.
*/
suspend fun Note.cacheVerifyOts(resolver: OtsResolver): VerificationState {
val event = event as? OtsEvent ?: return VerificationState.Error("Not an OTS event")
return when (val current = otsVerification) {
is VerificationState.Verified -> current
is VerificationState.Error -> current
is VerificationState.NetworkError ->
if (current.time < TimeUtils.fiveMinutesAgo()) verifyOts(event, resolver) else current
// null, or a leftover non-terminal state from an interrupted run: (re)verify.
else -> verifyOts(event, resolver)
}
}
/**
* Verifies the attestation and stores ONLY the terminal verdict. We deliberately never persist a
* `Verifying` sentinel: this runs inside a cancellable `LoadOts` LaunchedEffect, so if the coroutine
* were cancelled at the network suspension point after writing `Verifying` but before the verdict,
* that sentinel would stick on the (long-lived) note forever — there is no LRU eviction to recover
* it, unlike the old VerificationStateCache — and the OTS pill would silently vanish. Leaving the
* field untouched until a real verdict lands means a cancelled run simply retries on the next read;
* the cost is at worst two concurrent first-time verifications, which is harmless.
*/
private suspend fun Note.verifyOts(
event: OtsEvent,
resolver: OtsResolver,
): VerificationState = event.verifyState(resolver).also { otsVerification = it }
/**
* The earliest blockchain-verified time (unix seconds) among this note's non-expired OTS
* attestations, or null when none verify. Reads already-cached verdicts first — so a proof that was
* verified on arrival shows without waiting on the network — then verifies any still-unresolved
* proofs. Must run off the main thread.
*/
suspend fun Note.earliestOtsVerifiedTime(resolver: OtsResolver): Long? {
val now = TimeUtils.now()
var minTime: Long? = null
fun consider(time: Long) {
if (minTime.let { it == null || time < it }) minTime = time
}
val live =
timestamps.filter {
val e = it.event
e is OtsEvent && !e.isExpirationBefore(now)
}
val unresolved =
live.filter { proof ->
val verified = (proof.justOtsVerification() as? VerificationState.Verified)?.verifiedTime
if (verified != null) {
consider(verified)
false
} else {
true
}
}
unresolved.forEach { proof ->
(proof.cacheVerifyOts(resolver) as? VerificationState.Verified)?.verifiedTime?.let(::consider)
}
return minTime
}
@@ -36,6 +36,18 @@ class Nip11CachedRetriever(
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
private val retriever = Nip11Retriever(okHttpClient)
/**
* Drops any cached NIP-11 document (including a cached *error*) for [relay], so the next
* [loadRelayInfo] performs a fresh fetch. Used when the transport to a relay changes — e.g. it's
* marked Trusted to move off Tor onto clearnet — so a doc that failed over the old transport
* (403/timeout) isn't served from cache for the rest of its TTL, which would keep the relay's
* `self` key unknown and hide its relay-signed NIP-29 groups.
*/
fun invalidate(relay: NormalizedRelayUrl) {
relayInformationDocumentCache.remove(relay)
relayInformationEmptyCache.remove(relay)
}
fun trimToSize(maxItems: Int) {
relayInformationDocumentCache.trimToSize(maxItems)
// relayInformationEmptyCache holds only lightweight display-name+favicon-url placeholders;
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Per-account cache of NWC wallets' kind 13194 info events, keyed by wallet
* service pubkey. One fetch backs every capability question we ask about a
* wallet — the advertised encryption schemes (NIP-44 vs NIP-04 negotiation), the
* supported RPC methods, and whether it emits notifications.
*
* Entries expire after [ttlSeconds] (default 2 days) so a wallet that later
* changes its advertised capabilities is eventually re-checked. Reads never block
* on the network:
*
* - [current] returns whatever is cached (possibly stale, possibly null) with no
* side effect — for the payment hot path.
* - [refreshIfStale] triggers a background fetch when the entry is missing or
* expired, and returns immediately — call it right before using a wallet so a
* stale entry self-heals without holding up the transaction.
* - [getFresh] is the suspending variant for callers that can await (e.g. the
* notification watcher deciding whether to open a subscription).
*
* A completed fetch — including a definitive "wallet published no info event"
* (null) — is cached with a timestamp. A *failed* fetch (network error/timeout)
* is never cached, so a transient error retries on the next use instead of
* pinning the wallet to the fallback for the whole TTL window.
*/
class NwcInfoCache(
private val fetch: suspend (Nip47WalletConnect.Nip47URINorm) -> NwcInfoEvent?,
private val scope: CoroutineScope,
private val ttlSeconds: Long = DEFAULT_TTL_SECONDS,
private val now: () -> Long = { TimeUtils.now() },
) {
private class Entry(
val info: NwcInfoEvent?,
val fetchedAt: Long,
)
private val cache = ConcurrentHashMap<HexKey, Entry>()
private val inFlight = ConcurrentHashMap.newKeySet<HexKey>()
private fun isFresh(entry: Entry): Boolean = now() - entry.fetchedAt < ttlSeconds
/** Non-blocking read of the currently cached info event (may be stale or null). */
fun current(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? = cache[uri.pubKeyHex]?.info
/**
* Non-blocking. Kicks off a background fetch when the wallet's entry is missing
* or expired; a fetch already running for that wallet is not duplicated. Safe
* to call on the hot path — it never suspends.
*/
fun refreshIfStale(uri: Nip47WalletConnect.Nip47URINorm) {
val entry = cache[uri.pubKeyHex]
if (entry != null && isFresh(entry)) return
if (!inFlight.add(uri.pubKeyHex)) return
scope.launch(Dispatchers.IO) {
try {
fetchAndStore(uri)
} finally {
inFlight.remove(uri.pubKeyHex)
}
}
}
/**
* Suspends until a fresh-enough info event is available, fetching when the
* entry is missing or expired. Returns the last cached (possibly stale) value
* if the fetch fails.
*/
suspend fun getFresh(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
val entry = cache[uri.pubKeyHex]
if (entry != null && isFresh(entry)) return entry.info
return fetchAndStore(uri)
}
private suspend fun fetchAndStore(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
val info =
try {
fetch(uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
return cache[uri.pubKeyHex]?.info // keep the old value; retry on next use
}
cache[uri.pubKeyHex] = Entry(info, now())
return info
}
companion object {
const val DEFAULT_TTL_SECONDS = 2L * 24 * 60 * 60 // 2 days
}
}
@@ -37,14 +37,25 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -60,6 +71,13 @@ class NwcSignerState(
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
/**
* Shared cache of wallets' kind 13194 info events, used here to negotiate
* encryption. Injected by [com.vitorpamplona.amethyst.model.Account] (which
* owns the relay client). Null in tests / when unavailable — requests then
* fall back to NIP-04.
*/
val infoCache: NwcInfoCache? = null,
) : INwcSignerState {
/**
* Flow of the default wallet's NWC URI, derived from multi-wallet settings.
@@ -105,6 +123,31 @@ class NwcSignerState(
NostrSignerInternal(KeyPair(it))
}
init {
// Warm the info cache in the background whenever the default wallet changes
// so the payment hot path can read the encryption preference without waiting.
scope.launch(Dispatchers.IO) {
defaultWalletUri
.filterNotNull()
.distinctUntilChanged { a, b -> a.pubKeyHex == b.pubKeyHex && a.relayUri == b.relayUri }
.collect { infoCache?.refreshIfStale(it) }
}
}
/**
* Non-blocking read of the negotiated encryption preference for a wallet.
* NIP-47 says a client "should always prefer nip44 if supported by the wallet
* service". Returns true only when the cached info event advertises `nip44_v2`;
* otherwise NIP-04 (the legacy default). Also nudges a background refresh so a
* stale/expired entry self-heals for the next transaction without blocking this
* one.
*/
private fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
uri ?: return false
infoCache?.refreshIfStale(uri)
return infoCache?.current(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } ?: false
}
fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty()
override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey
@@ -119,6 +162,42 @@ class NwcSignerState(
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
}
// Non-zap incoming payments reported by connected wallets (NIP-47
// payment_received). Buffered + drop-oldest so a burst never blocks the
// decrypt coroutine; consumers (e.g. the tray-notification poster) collect it.
private val _incomingNonZapPayments =
MutableSharedFlow<NwcTransaction>(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val incomingNonZapPayments: SharedFlow<NwcTransaction> = _incomingNonZapPayments.asSharedFlow()
/**
* Decrypts an incoming NWC notification (kind 23197/23196) with the matching
* wallet's connection secret and, when it is a non-zap `payment_received`,
* publishes its transaction to [incomingNonZapPayments]. Zap-carrying payments
* are dropped — those already surface via the kind-9735 ZapNotification path.
*/
suspend fun handleIncomingNotification(event: NwcNotificationEvent) {
if (!hasWalletConnectSetup()) return
// The notification is `p`-tagged to the per-wallet client pubkey; match it
// to the wallet whose connection secret derives that key.
val clientPubKey = event.clientPubKey() ?: return
val wallet = settings.nwcWallets.value.firstOrNull { buildSigner(it.uri)?.pubKey == clientPubKey } ?: return
val walletSigner = buildSigner(wallet.uri) ?: return
val notification =
try {
event.decryptNotification(walletSigner)
} catch (e: Exception) {
if (e is CancellationException) throw e
return
}
val tx = (notification as? PaymentReceivedNotification)?.notification ?: return
if (tx.parsedMetadata()?.nostr != null) return // zap — already shown by ZapNotification
_incomingNonZapPayments.tryEmit(tx)
}
/**
* Sends a generic NIP-47 request to the default wallet.
*/
@@ -138,7 +217,7 @@ class NwcSignerState(
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
val walletSigner = buildSigner(walletService) ?: signer
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner)
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
val filter =
NWCPaymentQueryState(
@@ -184,7 +263,7 @@ class NwcSignerState(
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value)
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService))
val filter =
NWCPaymentQueryState(
@@ -28,8 +28,6 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -39,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
@@ -101,7 +100,6 @@ class CashuWalletState(
private val signer: NostrSigner,
private val cache: LocalCache,
private val scope: CoroutineScope,
private val assembler: CashuWalletFilterAssembler,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
@@ -313,6 +311,19 @@ class CashuWalletState(
*/
suspend fun exportP2pkPrivkeyHex(): String? = walletPrivkeyHex()
/**
* Private keys that can sign a NUT-11 P2PK witness when redeeming a pasted
* `cashuA`/`cashuB` token — see [CashuWalletOps.redeemToken].
*
* `first` is the wallet's kind:17375 P2PK key (for tokens locked to our
* wallet key, e.g. an inbound nutzap handed over out-of-band). `second` is
* the account identity key, present ONLY for a local nsec signer — some
* senders (e.g. Bey Wallet's P2PK send) lock ecash directly to the
* recipient's npub, and only a local key can produce that raw signature.
* A remote (NIP-46) / external (NIP-55) signer yields null there.
*/
suspend fun redeemSigningKeys(): Pair<String?, String?> = walletPrivkeyHex() to (signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey()
private suspend fun walletPrivkeyHex(): String? =
_walletEvent.value?.let { evt ->
runCatching { evt.privkey(signer) }.getOrNull()
@@ -377,7 +388,6 @@ class CashuWalletState(
// Lifecycle
// ============================================================
private val jobs = mutableListOf<Job>()
private var currentSubscription: CashuWalletQueryState? = null
@Volatile private var started = false
@@ -455,21 +465,11 @@ class CashuWalletState(
// our own kind:10019 — and another client may have published that
// with relays unrelated to our NIP-65 lists — so we listen on the
// union of those plus our NIP-65 inbox + DM relays.
jobs +=
scope.launch(Dispatchers.IO) {
combine(
outboxRelaysFlow,
inboxRelaysFlow,
dmRelaysFlow,
_nutzapInfoEvent,
) { outbox, inbox, dm, info ->
CashuWalletQueryState(
pubkey = pubKey,
ownEventRelays = outbox,
inboxRelays = inbox + dm + (info?.relays() ?: emptyList()),
)
}.collect { syncSubscription(it) }
}
// The relay subscription for this wallet is NOT here. It lives in
// CashuWalletEoseManager, inside the account-level assembler group, so it mounts and
// unmounts with every other account-level loader instead of running for the whole life of
// the Account object. What stays below is wallet *state*: indexing what arrives, and the
// local bookkeeping around it.
// Reactive incremental update: any new event arrival that matches our
// pubkey + the NIP-60/61 kinds we care about gets indexed.
@@ -524,25 +524,6 @@ class CashuWalletState(
fun destroy() {
jobs.forEach { it.cancel() }
jobs.clear()
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
}
// ============================================================
// Subscription management
// ============================================================
private fun syncSubscription(next: CashuWalletQueryState) {
val previous = currentSubscription
if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) {
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
return
}
if (previous == next) return // unchanged
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = next
assembler.subscribe(next)
}
// ============================================================
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the NIP-OA attestations this device holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
* [NamecoinSharedPreferences] (not per-account — the store is already keyed by the agent
* pubkey each attestation authorizes).
*
* On construction it loads the saved entries into the singleton — **re-verifying each
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
* — then mirrors every later change back to disk. Construct once, eagerly, at startup.
*/
@Stable
class BuzzAttestationPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
private val json = Json { ignoreUnknownKeys = true }
@Serializable
private data class Entry(
val agent: HexKey,
val owner: HexKey,
val conditions: String,
val sig: HexKey,
)
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value
// present at collection start, which restoreFromDisk already wrote).
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val verified =
json
.decodeFromString<List<Entry>>(raw)
.mapNotNull { e ->
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
// Only reinstate a credential that still verifies for its agent key.
if (attestation.verify(e.agent)) e.agent to attestation else null
}.toMap()
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
}
}
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
try {
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = json.encodeToString(list)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
}
}
companion object {
private val KEY = stringPreferencesKey("buzz.heldAttestations")
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]),
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
* loads the saved ids into the singleton on construction, then writes every later change back.
* Construct once, eagerly.
*/
@Stable
class BuzzChannelStarPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
BuzzChannelStars.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
}
}
private suspend fun persist(ids: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
* against on a cold start — Buzz membership is server-side (granted by the HTTP invite claim),
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
* relay is workspace-wide, and restoring only marks relays to sync — the relay still gates every
* read/write by the authenticated key).
*
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
*/
@Stable
class BuzzWorkspacePreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value present
// at collection start, which restoreFromDisk already wrote).
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
}
}
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = relays.map { it.url }.toSet()
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error writing joined workspaces: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]),
* so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps
* re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not
* per-account), loads the saved keys into the singleton on construction, then writes every later change
* back. Construct once, eagerly.
*/
@Stable
class RelayGroupDeletionPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
RelayGroupDeletions.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" }
}
}
private suspend fun persist(keys: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("nip29.deletedChannels")
}
}
@@ -41,10 +41,6 @@ import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -113,7 +109,6 @@ class UiSharedPreferences(
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts")
val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts")
val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items")
val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab")
val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab")
val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab")
@@ -154,7 +149,6 @@ class UiSharedPreferences(
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarEntries,
showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true,
showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true,
showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false,
@@ -209,7 +203,6 @@ class UiSharedPreferences(
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
preferences[UI_BOTTOM_BAR_ITEMS] = encodeBottomBarItems(sharedSettings.bottomBarItems)
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
@@ -231,42 +224,5 @@ class UiSharedPreferences(
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
}
}
/**
* Persists "follow the defaults" as a blank sentinel instead of the concrete default list.
*
* A user who resets the bottom bar (or who never customized it) should track whatever
* [DefaultBottomBarEntries] is in the *installed* app version. Storing the concrete list would
* pin them to today's default, so a future version that changes the default would never reach
* them. Storing a blank value instead makes [decodeBottomBarItems] resolve it back to the
* current [DefaultBottomBarEntries] on every load — i.e. the user is automatically migrated to
* the new default. Any genuinely customized bar is still stored as JSON.
*/
internal fun encodeBottomBarItems(items: List<BottomBarEntry>): String = if (items == DefaultBottomBarEntries) "" else JsonMapper.toJson(items)
internal fun decodeBottomBarItems(raw: String): List<BottomBarEntry>? {
if (raw.isBlank()) return DefaultBottomBarEntries
// Current format: a JSON list of BottomBarEntry (built-ins + favorites).
runCatching { return JsonMapper.fromJson<List<BottomBarEntry>>(raw) }
// Configs written before the stable @SerialName discriminators used the fully-qualified
// class name as the polymorphic "type" value. Rewrite it to the short name and retry, so a
// customized bar survives the upgrade instead of silently resetting to defaults.
runCatching {
val migrated =
raw
.replace(LEGACY_BUILTIN_DISCRIMINATOR, "builtIn")
.replace(LEGACY_FAVORITE_DISCRIMINATOR, "favorite")
return JsonMapper.fromJson<List<BottomBarEntry>>(migrated)
}
// Oldest format: comma-joined NavBarItem enum names (before favorites/unified entries).
val legacy = raw.split(",").mapNotNull { name -> runCatching { NavBarItem.valueOf(name) }.getOrNull() }
if (legacy.isNotEmpty()) return legacy.map { BottomBarEntry.BuiltIn(it) }
// Unrecognizable — fall back to the defaults rather than leaving the bar empty.
return DefaultBottomBarEntries
}
// The pre-@SerialName polymorphic discriminators (fully-qualified class names) for migration.
private const val LEGACY_BUILTIN_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.BuiltIn"
private const val LEGACY_FAVORITE_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.Favorite"
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,8 +20,9 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,11 +21,11 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.compute50kmRange
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.global
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.relay
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
@@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.withoutClientTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeout
@@ -155,7 +156,10 @@ class AccountNappletGateways(
)
}
return NappletBroker(account.signer, ledger, consent, signerLedger = signerLedger, nostrConnectPrompt = connectPrompt, signerConsentPrompt = signerConsent, relay = relay, storage = storage, wallet = wallet, resource = resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify)
// Everything the broker signs belongs to the guest — a napplet, an nSite, or a web app
// calling NIP-07 — never to Amethyst, so our client tag has no business on it. It would also
// corrupt the template a NIP-07 caller re-checks the returned event against.
return NappletBroker(account.signer.withoutClientTag(), ledger, consent, signerLedger = signerLedger, nostrConnectPrompt = connectPrompt, signerConsentPrompt = signerConsent, relay = relay, storage = storage, wallet = wallet, resource = resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify)
}
/**
@@ -67,6 +67,7 @@ class ZapPaymentHandler(
val weight: Double = 1.0,
val relay: NormalizedRelayUrl? = null,
val user: User? = null,
val bolt12Offer: String? = null,
)
data class MyZapSplitSetup(
@@ -76,6 +77,13 @@ class ZapPaymentHandler(
val user: User? = null,
)
/** A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we hold an NWC wallet. */
data class Bolt12Recipient(
val user: User,
val offer: String,
val weight: Double = 1.0,
)
suspend fun zap(
note: Note,
amountMilliSats: Long,
@@ -111,6 +119,7 @@ class ZapPaymentHandler(
weight = setup.weight,
relay = setup.relay,
user = user,
bolt12Offer = user?.bolt12Offers()?.firstOrNull(),
)
}
}
@@ -121,7 +130,7 @@ class ZapPaymentHandler(
noteEvent.hosts().map {
val user = LocalCache.checkGetOrCreateUser(it.pubKey)
val lnAddress = user?.lnAddress()
UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user)
UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user, bolt12Offer = user?.bolt12Offers()?.firstOrNull())
}
}
@@ -130,23 +139,62 @@ class ZapPaymentHandler(
if (appLud16 != null) {
listOf(UnverifiedZapSplitSetup(appLud16))
} else {
val lud16 =
note.author?.lnAddress()
listOf(UnverifiedZapSplitSetup(lud16))
val author = note.author
listOf(UnverifiedZapSplitSetup(author?.lnAddress(), user = author, bolt12Offer = author?.bolt12Offers()?.firstOrNull()))
}
}
else -> {
val author = note.author
listOf(
UnverifiedZapSplitSetup(
note.author?.lnAddress(),
lnAddress = author?.lnAddress(),
user = author,
bolt12Offer = author?.bolt12Offers()?.firstOrNull(),
),
)
}
}
// BOLT12-first: a recipient who publishes a kind:10058 offer is zapped over
// BOLT12 when our default NWC wallet advertises the nwc#2 `pay` method (needed for
// the payer proof). Otherwise — no wallet, or a wallet without `pay` — the recipient
// stays on lightning, so an unsupported wallet degrades gracefully instead of erroring.
val canBolt12 =
account.settings.nwcWallets.value
.isNotEmpty() &&
account.defaultWalletSupportsBolt12Pay()
val bolt12Recipients =
unverifiedZapsToSend.mapNotNull {
val user = it.user
if (canBolt12 && it.bolt12Offer != null && user != null) {
Bolt12Recipient(user, it.bolt12Offer, it.weight)
} else {
null
}
}
val bolt12Users = bolt12Recipients.mapTo(HashSet()) { it.user }
val zapsToSend =
unverifiedZapsToSend.mapNotNull {
// Never lightning-zap a recipient already routed over BOLT12.
if (it.user != null && it.user in bolt12Users) {
null
} else if (it.lnAddress != null) {
MyZapSplitSetup(it.lnAddress, it.weight, it.relay, it.user)
} else {
null
}
}
if (showErrorIfNoLnAddress) {
val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() }
// Only a recipient with neither a BOLT12 route nor an lnAddress is unpayable.
val errors =
unverifiedZapsToSend.filter {
val routedBolt12 = canBolt12 && it.bolt12Offer != null && it.user != null
!routedBolt12 && it.lnAddress.isNullOrBlank()
}
errors.forEach {
val message =
if (it.user != null) {
@@ -167,71 +215,77 @@ class ZapPaymentHandler(
}
}
val zapsToSend =
unverifiedZapsToSend.mapNotNull {
if (it.lnAddress != null) {
MyZapSplitSetup(
it.lnAddress,
it.weight,
it.relay,
it.user,
)
} else {
null
}
}
// Weight is shared across both lanes so splits stay proportional regardless of rail.
val totalWeight = bolt12Recipients.sumOf { it.weight } + zapsToSend.sumOf { it.weight }
if (totalWeight <= 0.0) {
onProgress(0.00f)
return@withContext
}
onProgress(0.02f)
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats)
// --- Lightning lane -----------------------------------------------------------
if (zapsToSend.isNotEmpty()) {
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats, totalWeight)
if (splitZapRequests.isEmpty()) {
onProgress(0.00f)
return@withContext
} else {
onProgress(0.05f)
if (splitZapRequests.isNotEmpty()) {
onProgress(0.05f)
val payables =
assembleAllInvoices(
requests = splitZapRequests,
totalAmountMilliSats = amountMilliSats,
message = message,
okHttpClient = okHttpClient,
onError = onError,
onProgress = { onProgress(it * 0.7f + 0.05f) },
context = context,
totalWeight = totalWeight,
)
if (payables.isNotEmpty()) {
onProgress(0.75f)
// Route through the user's selected default payment source. A CLINK debit takes
// precedence over NWC when it is the chosen default; NWC-only users are unaffected
// (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app.
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f)
}, context)
}
is PaymentSource.Nwc -> {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context)
}
null -> {
onPayViaIntent(payables.toImmutableList())
}
}
}
}
}
val payables =
assembleAllInvoices(
requests = splitZapRequests,
// --- BOLT12 lane --------------------------------------------------------------
if (bolt12Recipients.isNotEmpty()) {
payViaBolt12(
recipients = bolt12Recipients,
note = note,
totalAmountMilliSats = amountMilliSats,
totalWeight = totalWeight,
message = message,
okHttpClient = okHttpClient,
zapType = zapType,
onError = onError,
onProgress = { onProgress(it * 0.7f + 0.05f) },
onProgress = { onProgress(it * 0.25f + 0.75f) },
context = context,
)
if (payables.isEmpty()) {
onProgress(0.00f)
return@withContext
} else {
onProgress(0.75f)
}
// Route through the user's selected default payment source. A CLINK debit takes
// precedence over NWC when it is the chosen default; NWC-only users are unaffected
// (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app.
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f)
}, context)
}
is PaymentSource.Nwc -> {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context)
// onProgress(1f)
}
null -> {
onPayViaIntent(payables.toImmutableList())
onProgress(0f)
}
}
onProgress(1f)
}
private fun calculateZapValue(
@@ -256,9 +310,10 @@ class ZapPaymentHandler(
zapType: LnZapEvent.ZapType,
zapsToSend: List<MyZapSplitSetup>,
totalAmountMilliSats: Long,
): List<ZapRequestReady> {
val totalWeight = zapsToSend.sumOf { it.weight }
return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
// Shared across the lightning + BOLT12 lanes so a mixed split stays proportional.
totalWeight: Double = zapsToSend.sumOf { it.weight },
): List<ZapRequestReady> =
mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
// makes sure the author receives the zap event
val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
@@ -291,7 +346,6 @@ class ZapPaymentHandler(
ZapRequestReady(next, zapRequest)
}
}
suspend fun assembleAllInvoices(
requests: List<ZapRequestReady>,
@@ -301,9 +355,10 @@ class ZapPaymentHandler(
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
// Shared across the lightning + BOLT12 lanes so a mixed split stays proportional.
totalWeight: Double = requests.sumOf { it.inputSetup.weight },
): List<Payable> {
var progressAllPayments = 0.00f
val totalWeight = requests.sumOf { it.inputSetup.weight }
return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady ->
try {
@@ -386,6 +441,47 @@ class ZapPaymentHandler(
)
}
/**
* BOLT12 zap rail (NIP-B1). For each recipient that publishes a kind:10058 offer,
* signs a 9737 intent, pays the offer over NWC with the intent-bound `payer_note`,
* and (if the returned proof validates) publishes a 9736 zap — see
* [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic
* and settlement/errors surface later through the async NWC response.
*/
suspend fun payViaBolt12(
recipients: List<Bolt12Recipient>,
note: Note,
totalAmountMilliSats: Long,
totalWeight: Double,
message: String,
zapType: LnZapEvent.ZapType,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
) {
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
account.sendBolt12Zap(
zappedEvent = note.event,
recipientPubKey = recipient.user.pubkeyHex,
offer = recipient.offer,
amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight),
message = message,
zapType = zapType,
onError = { msgRes, detail ->
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
},
onProcessed = { progress.step() },
)
progress.step()
recipient
}
}
/**
* Thread-safe progress accumulator for the parallel pay rails. Each payable advances in two
* half-steps (request dispatched, then response/settlement), reported as a 0..1 fraction.
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.buzz
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Mints a `block/buzz` workspace invite link by POSTing to the relay's **Buzz-specific**
* `/api/invites` HTTP endpoint (NIP-98 signed; owner/admin only). Not a Nostr event and not a NIP —
* only the NIP-98 auth is standard — so this is Buzz-only and lives outside quartz.
*
* The endpoint host is the relay's own host (the invite link is `https://<host>/invite/<code>`), so
* we never need to own a domain — the relay does, and it checks the signer is an owner/admin. See
* `crates/buzz-relay/src/api/invites.rs`.
*/
object BuzzInviteMinter {
private val json = ObjectMapper()
/** A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). */
data class MintedInvite(
val code: String,
val url: String,
val expiresAt: Long,
)
/**
* POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (relay clamps to [60, 30d];
* default 72 h). [httpAuth] signs the NIP-98 event over the exact URL + body; [okHttpClient]
* supplies the transport (use a trusted-relay-posture client so a Cloudflare-fronted relay is
* reached over clearnet). Throws [IllegalStateException] with the relay's error slug on failure.
*/
suspend fun mint(
relay: NormalizedRelayUrl,
ttlSecs: Long?,
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent,
): MintedInvite =
withContext(Dispatchers.IO) {
// wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root.
val wsUrl = relay.url
val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http"
val host = wsUrl.substringAfter("://").substringBefore("/")
// Parse once into an HttpUrl and sign over ITS canonical string, so the NIP-98 `u` tag is
// byte-for-byte what OkHttp transmits (host casing / encoding can't drift the two apart).
val httpUrl = "$scheme://$host/api/invites".toHttpUrl()
val url = httpUrl.toString()
// Exact bytes the NIP-98 payload hash is computed over — must equal what we send.
val bodyStr = ttlSecs?.let { "{\"ttl_secs\":$it}" } ?: "{}"
val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
val auth = httpAuth(url, "POST", bodyBytes)
val request =
Request
.Builder()
.url(httpUrl)
.addHeader("Authorization", auth.toAuthToken())
.post(bodyBytes.toRequestBody("application/json".toMediaType()))
.build()
okHttpClient(url).newCall(request).execute().use { response ->
val payload = response.body.string()
val tree = runCatching { json.readTree(payload) }.getOrNull()
if (!response.isSuccessful) {
val slug = tree?.get("error")?.asText() ?: "HTTP ${response.code}"
throw IllegalStateException(slug)
}
MintedInvite(
code = tree?.get("code")?.asText().orEmpty(),
url = tree?.get("url")?.asText().orEmpty(),
expiresAt = tree?.get("expires_at")?.asLong() ?: 0L,
)
}
}
}
@@ -75,6 +75,20 @@ abstract class FlowProgressForegroundService<T> : Service() {
/** The intent action that routes back here to cancel everything. */
protected abstract val cancelAction: String
protected abstract val cancelLabelRes: Int
/**
* Optional second notification action (e.g. "send without PoW"). When
* [secondaryAction] and [secondaryLabelRes] are both non-null, the button is
* shown and tapping it routes back here to run [onSecondaryAction] without
* stopping the service — the work keeps going and the card drains normally.
*/
protected open val secondaryAction: String? = null
protected open val secondaryLabelRes: Int? = null
protected open fun onSecondaryAction() {
// No-op by default: only subclasses that declare a secondary action override this.
}
protected open val smallIcon: Int = R.drawable.amethyst
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
@@ -145,6 +159,17 @@ abstract class FlowProgressForegroundService<T> : Service() {
)
}
private val secondaryIntent: PendingIntent? by lazy {
secondaryAction?.let { action ->
PendingIntent.getService(
this,
2,
Intent(this, this.javaClass).setAction(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
@@ -168,6 +193,14 @@ abstract class FlowProgressForegroundService<T> : Service() {
return START_NOT_STICKY
}
if (intent?.action != null && intent.action == secondaryAction) {
// keep the service alive: the jobs flip to publishing and the watch
// loop stops us once the queue drains.
onSecondaryAction()
watch()
return START_NOT_STICKY
}
watch()
return START_NOT_STICKY
}
@@ -252,20 +285,28 @@ abstract class FlowProgressForegroundService<T> : Service() {
.setProgress(bar.done)
}
return NotificationCompat
.Builder(this, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(content.title)
.setContentText(content.text)
.setStyle(style)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
val builder =
NotificationCompat
.Builder(this, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(content.title)
.setContentText(content.text)
.setStyle(style)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
val secondaryLabel = secondaryLabelRes
val secondaryPending = secondaryIntent
if (secondaryLabel != null && secondaryPending != null) {
builder.addAction(0, stringRes(this, secondaryLabel), secondaryPending)
}
return builder.build()
}
private fun ensureChannel() {
@@ -21,56 +21,137 @@
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
/**
* Wraps [LocationManager] update registration as a cold [Flow].
*
* Registers on **one** provider, chosen by [LocationProviderLadder], rather than
* on every provider the device reports. The previous shotgun cost four
* simultaneous registrations — passive, network, fused and gps, the last at
* HIGH_ACCURACY — to produce a 5 km geohash.
*
* Takes a [LocationManager] rather than a `Context` so the registration
* behaviour is unit-testable; the caller does the `getSystemService` lookup.
*
* [onListening] is fired from inside the flow, after a registration succeeds, and
* released again from the `try`/`finally` that wraps everything after it, never
* as an `onStart`/`onCompletion` pair on the returned flow. The distinction
* matters: an `onStart` fires on collection even when nothing registered, so a
* device with no usable provider would accrue location time with no location
* running, and — because the ledger refcounts the two [LocationState] flows
* together — the unpaired close would steal the other flow's holder.
*
* The pair is kept honest from both ends. The acquire cannot fire without a
* registration, because a failure to register throws before reaching it. The
* release cannot be skipped, because everything after the acquire runs inside a
* `try`/`finally` rather than inside `awaitClose` — [freshestLastKnownLocation]
* (the seed sweep below) can throw a non-cancellation exception and unwind
* before `awaitClose` is ever reached, and `try`/`finally` is what still runs
* the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in
* `LocationFlowTest`. (In principle a collector cancelling mid-seed would also
* unwind past `awaitClose` the same way, but that path could not be
* reproduced — `callbackFlow`'s buffer is empty at this point, so the single
* seed send returns without suspending and never observes the cancellation.)
*/
class LocationFlow(
private val context: Context,
private val locationManager: LocationManager,
private val sdkInt: Int = Build.VERSION.SDK_INT,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
minTimeMs: Long,
minDistanceM: Float,
onListening: ((Boolean) -> Unit)? = null,
): Flow<Location> =
callbackFlow {
Log.i("LocationFlow", "Start")
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
LocationListener { location ->
Log.d("LocationFlow") { "onLocationChanged $location" }
launch { send(location) }
}
locationManager.allProviders.forEach {
val location = locationManager.getLastKnownLocation(it)
Log.d("LocationFlow") { "Last Known location is $location" }
if (location != null) {
send(location)
}
Log.d("LocationFlow", "Requesting Updates")
locationManager.requestLocationUpdates(
it,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
}
// One binder call, reused for both the ladder filter and the seed.
val providers = locationManager.allProviders
awaitClose {
Log.i("LocationFlow", "Stop")
val candidates = LocationProviderLadder.chooseProviders(sdkInt) { it in providers }
val registered =
candidates.firstOrNull { requestUpdates(it, minTimeMs, minDistanceM, locationCallback) }
?: throw SecurityException("No usable location provider. Candidates: $candidates")
Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" }
onListening?.invoke(true)
// Cleanup lives in this finally, not in awaitClose — see the class
// KDoc, and `releasesTheRegistrationWhenTheSeedThrows` in
// LocationFlowTest, which fails if it moves.
try {
// Seeded after registration so the no-provider path throws
// without having emitted anything; seeding first would show the
// consumer Success -> LackPermission on a device with no
// compatible provider.
freshestLastKnownLocation(candidates)?.let {
Log.d("LocationFlow") { "Last known location is $it" }
send(it)
}
awaitClose { }
} finally {
Log.i("LocationFlow") { "Stopped listening on $registered" }
locationManager.removeUpdates(locationCallback)
onListening?.invoke(false)
}
}
/** True when the registration was accepted; false when the provider refused it. */
@SuppressLint("MissingPermission")
private fun requestUpdates(
provider: String,
minTimeMs: Long,
minDistanceM: Float,
locationCallback: LocationListener,
): Boolean =
try {
locationManager.requestLocationUpdates(
provider,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
true
} catch (e: SecurityException) {
Log.w("LocationFlow", "Provider $provider refused the update request", e)
false
}
/**
* The freshest cached fix across the providers the ladder deemed usable.
* Sweeping every provider the device reports instead would, on the
* coarse-only legacy path, pay a guaranteed-to-throw binder call per
* fine-only provider on every flow start. Still guarded per provider, like
* the update request is — on a device where one refuses us, the others
* should still seed.
*/
@SuppressLint("MissingPermission")
private fun freshestLastKnownLocation(providers: List<String>): Location? =
providers
.mapNotNull { provider ->
try {
locationManager.getLastKnownLocation(provider)
} catch (e: SecurityException) {
Log.w("LocationFlow", "No permission to read the last known location of $provider", e)
null
}
}.maxByOrNull { it.time }
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.location.LocationManager
import android.os.Build
/**
* Picks which location providers to try, in order.
*
* Deliberately selects on **provider existence**, never on
* [LocationManager.isProviderEnabled]. A registration on a disabled provider
* goes live by itself when the user enables location — including from the
* quick-settings shade without leaving the app, which is exactly what someone
* does after seeing an empty "Around Me" feed. An enabled-state guard evaluated
* once at subscription start would lose that.
*
* Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`;
* only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which
* lets a coarse-only app request any provider and receive a fuzzed result, is an
* Android 12 change. Amethyst declares coarse only, so the legacy branch is
* unconditional below API 31.
*
* Adding `ACCESS_FINE_LOCATION` later would **not** widen this on its own — the
* branch below has no permission input, so pre-31 devices would keep getting
* `network` alone and silently lose the precision the new permission was granted
* for. Whoever adds it must widen the condition here too.
*
* Returns the ordered candidate list rather than a single choice so the caller
* can fall through to the next rung if a registration is refused. An empty list
* means no compatible provider exists.
*/
object LocationProviderLadder {
// Compile-time String constants, inlined by the compiler, so naming
// FUSED_PROVIDER (added in API 31) is safe on older runtimes.
private val FULL_LADDER =
listOf(
LocationManager.FUSED_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.GPS_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER)
fun chooseProviders(
sdkInt: Int,
exists: (String) -> Boolean,
): List<String> {
val ladder = if (sdkInt >= Build.VERSION_CODES.S) FULL_LADDER else COARSE_ONLY_LEGACY_LADDER
return ladder.filter(exists)
}
}
@@ -21,32 +21,83 @@
package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Location
import android.location.LocationManager
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same
// package, so it needs no import.
/**
* Turns the device's location into geohashes, listening **only while the app is
* in the foreground**.
*
* The gate is not an optimisation of last resort: `Account` builds 30
* `SharingStarted.Eagerly` top-nav filter states on the account scope, and
* `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so
* without it every user with location permission holds a registration for the
* life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`.
*
* Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60
* call sites read `account.live*FollowLists.value` synchronously rather than
* collecting, and would silently serve a stale or initial value.
*/
class LocationState(
context: Context,
scope: CoroutineScope,
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
private val scope: CoroutineScope,
private val isForeground: StateFlow<Boolean>,
/**
* Resource-ledger hook: true while location updates are actively
* registered. Reaches the OS only through the default [locationSource],
* which hands it to [LocationFlow] — a caller that overrides
* [locationSource] (the tests do) is responsible for firing it, or not.
*/
private val onListening: ((Boolean) -> Unit)? = null,
private val locationSource: (Long, Float) -> Flow<Location> = { minTimeMs, minDistanceM ->
LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
.get(minTimeMs, minDistanceM, onListening)
},
) {
companion object {
const val MIN_TIME: Long = 10000L
const val MIN_DISTANCE: Float = 100.0f
/** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */
const val COARSE_MIN_TIME: Long = 60_000L
const val COARSE_MIN_DISTANCE: Float = 500.0f
/** Building-level geohashes need the tighter profile. */
const val PRECISE_MIN_TIME: Long = 10_000L
const val PRECISE_MIN_DISTANCE: Float = 100.0f
/**
* How long to keep listening after the last activity stops, so a
* one-second app switch doesn't destroy and rebuild the registration.
* Same intent as [SUBSCRIPTION_STOP_TIMEOUT_MS], on the other axis.
*/
const val BACKGROUND_GRACE_MS: Long = 5_000L
/**
* How long `stateIn` keeps the upstream alive after the last collector
* leaves, so a screen rotation or a tab switch doesn't rebuild the
* registration either.
*/
const val SUBSCRIPTION_STOP_TIMEOUT_MS: Long = 5_000L
}
sealed class LocationResult {
@@ -59,9 +110,16 @@ class LocationState(
object Loading : LocationResult()
}
private enum class Gate { NoPermission, Paused, Listen }
private var hasLocationPermission = MutableStateFlow(false)
private var latestLocation: LocationResult = LocationResult.Loading
private var latestPreciseLocation: LocationResult = LocationResult.Loading
// Read by R1 below to decide whether to emit Loading, from a different
// coroutine than the onEach that writes it — hence a StateFlow rather than
// a plain field.
private val latestLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
private val latestPreciseLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
@@ -69,36 +127,92 @@ class LocationState(
}
}
/**
* Foreground with an asymmetric delay: leaving the foreground waits out
* [BACKGROUND_GRACE_MS], returning to it is immediate.
*
* `debounce(5000)` would delay both edges, and the duration-selector
* overload that allows an asymmetric delay is `@FlowPreview`.
* `transformLatest` cancels the pending `delay` when foreground returns
* first, which is exactly the semantics wanted, with no preview opt-in.
*
* Known and harmless: [ForegroundTracker] starts at `false`, so on a
* process that starts backgrounded the first emission — and therefore the
* first gate verdict, including `LackPermission` — is delayed by
* [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process
* that starts into the foreground emits immediately, because the activity's
* `onStart` cancels the pending delay.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val geohashStateFlow by lazy {
hasLocationPermission
.transformLatest {
if (it) {
emit(LocationResult.Loading)
val result =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onStart { onListening?.invoke(true) }
.onCompletion { onListening?.invoke(false) }
.map {
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
Log.w("GeohashStateFlow", "Exception in the flow", e)
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
}
private val settledForeground: Flow<Boolean> =
isForeground.transformLatest { foreground ->
if (!foreground) delay(BACKGROUND_GRACE_MS)
emit(foreground)
}
emitAll(result)
} else {
emit(LocationResult.LackPermission)
private val gate: Flow<Gate> =
combine(hasLocationPermission, settledForeground) { permitted, foreground ->
when {
!permitted -> Gate.NoPermission
foreground -> Gate.Listen
else -> Gate.Paused
}
}.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
private fun buildGeohashStateFlow(
tag: String,
charsCount: Int,
minTimeMs: Long,
minDistanceM: Float,
cache: MutableStateFlow<LocationResult>,
): StateFlow<LocationResult> =
gate
.transformLatest { state ->
when (state) {
// Deliberately does NOT write to the cache. Today's code emits
// LackPermission without touching the cache, and wiping it
// here would cost a Loading emission — and so an empty-feed
// flash — on every permission flap, which is the regression R1
// exists to prevent. Consumers already see LackPermission from
// the StateFlow; the cache is internal and only decides whether
// Loading is emitted.
Gate.NoPermission -> emit(LocationResult.LackPermission)
// Emit nothing: stateIn keeps the last value, so every
// synchronous .value reader still sees the last known geohash
// while the OS registration is released.
Gate.Paused -> Unit
Gate.Listen -> {
// Only when there is nothing cached. Emitting Loading on
// every foreground return would flash the "Around Me" feed
// empty, because AroundMeFeedFlow.convert maps anything
// that is not Success to an empty geotag set.
if (cache.value !is LocationResult.Success) emit(LocationResult.Loading)
emitAll(
locationSource(minTimeMs, minDistanceM)
.map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult }
.onEach { cache.value = it }
.catch { e ->
Log.w(tag, "Exception in the flow", e)
cache.value = LocationResult.LackPermission
emit(LocationResult.LackPermission)
},
)
}
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
}.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value)
val geohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "GeohashStateFlow",
charsCount = GeohashPrecision.KM_5_X_5.digits,
minTimeMs = COARSE_MIN_TIME,
minDistanceM = COARSE_MIN_DISTANCE,
cache = latestLocation,
)
}
/**
@@ -107,36 +221,19 @@ class LocationState(
* to every coarser level (a geohash is a prefix code), so one fix yields the
* whole region→building ladder. Kept separate so the coarser
* [geohashStateFlow] the "around me" feed relies on is unchanged.
*
* Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android
* fuzzes every fix to roughly a 3 km grid and this is not in fact
* building-level today. The profile is kept so the intent survives if the
* app ever requests `ACCESS_FINE_LOCATION`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val preciseGeohashStateFlow by lazy {
hasLocationPermission
.transformLatest {
if (it) {
emit(LocationResult.Loading)
val result =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onStart { onListening?.invoke(true) }
.onCompletion { onListening?.invoke(false) }
.map {
LocationResult.Success(it.toGeoHash(GeohashChannelLevel.BUILDING.chars)) as LocationResult
}.onEach {
latestPreciseLocation = it
}.catch { e ->
Log.w("GeohashStateFlow", "Exception in the precise flow", e)
latestPreciseLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
}
emitAll(result)
} else {
emit(LocationResult.LackPermission)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestPreciseLocation,
)
val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "PreciseGeohashStateFlow",
charsCount = GeohashChannelLevel.BUILDING.chars,
minTimeMs = PRECISE_MIN_TIME,
minDistanceM = PRECISE_MIN_DISTANCE,
cache = latestPreciseLocation,
)
}
}
@@ -22,13 +22,17 @@ package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -45,31 +49,45 @@ import kotlinx.coroutines.launch
* L4 - BootCompletedReceiver (restart on boot)
* L5 - ServiceWatchdogManager (AlarmManager, 5-min health check)
*
* Two switches gate the system:
* It also decides **which accounts pull from relays**, which is a different question from
* whether the service runs, and the two are deliberately not gated the same way.
*
* - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the
* "Background notification service" toggle / Quick Settings tile). When off, every
* layer is torn down and nothing restarts, regardless of any account's setting —
* this is the battery-saver "airplane mode". Persisted, so an explicit off survives
* restarts and crashes.
* - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService],
* "Keep this account active in the background") **or** its NIP-46 signer toggle
* ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is
* on, the service runs as long as **at least one** writable account has either flag on — the
* background signer relies on the same foreground service to keep answering requests.
* ## Who subscribes
*
* While the master is on, every saved writable account is kept loaded in
* [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps
* addressed to any of them (delivered via open relay subscriptions) get unwrapped by
* the owning account's `newNotesPreProcessor`. Without this, wraps for non-active
* accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no
* subscriber able to decrypt them.
* - **While a screen is up: every loaded account.** The user can switch accounts at any moment
* and expects the one they land on to be current, so all of them keep their own notifications,
* DMs and gift wraps live. This costs nothing once the app is away — it ends with the screen.
* - **While the app is away: only the accounts that opted in**, via
* [com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService] ("Keep this
* account active in the background") or their NIP-46 signer toggle
* ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]).
*
* That is what the setting's name promises, and for a while it did not hold: participation gated
* subscriptions everywhere, so an account you had not opted in for showed no notifications even
* with the app open in front of you.
*
* ## Whether the service runs
*
* The five layers are a background concern, so they stay gated on **both** the global master
* ([LocalPreferences.notificationServiceEnabledFlow], the "Background notification service"
* toggle / Quick Settings tile — the battery-saver "airplane mode", persisted so an explicit off
* survives restarts) **and** at least one account having opted in. A foreground-only account must
* never start a foreground service that outlives the screen that wanted it.
*
* Every saved writable account is kept loaded in [AccountCacheState] whenever either condition
* holds, so (a) participation flags are observable and (b) GiftWraps addressed to any of them get
* unwrapped by the owning account's `newNotesPreProcessor`. Without this, wraps for non-active
* accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber able to
* decrypt them.
*/
class AlwaysOnNotificationServiceManager(
private val context: Context,
private val scope: CoroutineScope,
private val accountsCache: AccountCacheState,
private val localPreferences: LocalPreferences,
private val subscriptions: AccountSubscriptionRegistry,
/** True while any activity is STARTED — see [com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker]. */
private val isForeground: StateFlow<Boolean>,
private val activePubKeyProvider: () -> HexKey?,
) {
companion object {
@@ -98,55 +116,84 @@ class AlwaysOnNotificationServiceManager(
wasEnabled = false
watchJob =
scope.launch {
localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled ->
if (!masterEnabled) {
// Global airplane mode: suppress every layer regardless of
// per-account participation, and stop keeping accounts loaded.
if (wasEnabled) {
disableServiceLayers()
wasEnabled = false
}
stopMultiAccountPreload()
return@collectLatest
}
// Master on: keep every writable account loaded so its participation
// flag is observable and its gift wraps can decrypt, then run the
// service only while at least one account is participating. An account
// participates when its always-on setting OR its NIP-46 signer toggle is
// on — the background signer needs the same foreground service alive.
startMultiAccountPreload()
accountsCache.accounts
.flatMapLatest { accounts ->
val flags =
accounts.values.map { account ->
account.settings.alwaysOnNotificationService
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer }
}
if (flags.isEmpty()) {
flowOf(false)
} else {
combine(flags) { values -> values.any { it } }
}
}.distinctUntilChanged()
.collectLatest { anyParticipating ->
if (anyParticipating) {
wasEnabled = true
enableServiceLayers()
} else if (wasEnabled) {
localPreferences
.notificationServiceEnabledFlow()
.combine(isForeground) { masterEnabled, foreground -> masterEnabled to foreground }
.collectLatest { (masterEnabled, foreground) ->
if (!masterEnabled && !foreground) {
// Nothing wants the accounts: the master is off and no screen is up.
// Suppress every layer and stop keeping accounts loaded.
if (wasEnabled) {
disableServiceLayers()
wasEnabled = false
}
stopMultiAccountPreload()
return@collectLatest
}
}
// Keep every writable account loaded — in the foreground so they can all
// pull, and with the master on so participation flags are observable and
// gift wraps can decrypt.
startMultiAccountPreload()
accountsAndParticipants()
.distinctUntilChanged()
.collectLatest { (all, participating) ->
// The rule the "keep this account active in the background" setting
// actually describes: while a screen is up, EVERY loaded account
// pulls its own notifications, DMs and gift wraps, because the user
// can switch to any of them and expects them current. The setting
// only decides which ones keep doing it once the app is away.
subscriptions.sync(if (foreground) all else participating)
// The service layers are a background concern, so they stay tied to
// the master switch and to somebody having opted in. A foreground-only
// account must not start a foreground service that outlives the screen.
//
// Edge-triggered, deliberately. This flow re-emits whenever the account
// map changes identity or the app crosses foreground — far more often
// than the old boolean did — and ServiceWatchdogManager.schedule()
// replaces its alarm with one starting `now + 5min`. Calling it on every
// emission pushed the watchdog's first fire past every screen-on, so the
// layer that exists to restart a dead service would never have run.
val shouldRun = masterEnabled && participating.isNotEmpty()
if (shouldRun != wasEnabled) {
if (shouldRun) enableServiceLayers() else disableServiceLayers()
wasEnabled = shouldRun
}
}
}
}
}
/**
* Every loaded account paired with the subset that opted into running in the background.
*
* Both come from one flow because they change together and the two decisions below — who
* subscribes, and whether the service runs — must never be made from different snapshots.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun accountsAndParticipants(): Flow<Pair<List<Account>, List<Account>>> =
accountsCache.accounts.flatMapLatest { accounts ->
val all = accounts.values.toList()
val flags =
all.map { account ->
account.settings.alwaysOnNotificationService
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer ->
if (alwaysOn || signer) account else null
}
}
if (flags.isEmpty()) {
flowOf(all to emptyList())
} else {
combine(flags) { values -> all to values.filterNotNull() }
}
}
fun stop() {
watchJob?.cancel()
watchJob = null
preloadJob?.cancel()
preloadJob = null
stopMultiAccountPreload()
// Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm
// and periodic worker stay scheduled and would resurrect the service for a
// logged-out user (nobody participating).
@@ -211,10 +258,15 @@ class AlwaysOnNotificationServiceManager(
* Cancels the preload collector and releases every cached account except the
* currently active one, so users with the master off return to single-account
* memory/battery footprint.
*
* Unmounts the background subscriptions too: with the master off, the only
* account that should be talking to relays is the one on screen, and its
* subscription comes from the screen's own mount.
*/
private fun stopMultiAccountPreload() {
preloadJob?.cancel()
preloadJob = null
subscriptions.clear()
// remove this because we don't know which other accounts might be getting used.
// val active = activePubKeyProvider()
// if (active != null) {
@@ -0,0 +1,275 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Logical grouping of notification channels, shown as a section header in the
* Android system-settings page (API 26+). Lets the user silence a whole family
* (e.g. all Social notifications) with one switch, in addition to the per-kind
* channel switches.
*/
enum class NotifChannelGroup(
val id: String,
@param:StringRes val nameRes: Int,
) {
MESSAGES("com.vitorpamplona.amethyst.group.messages", R.string.app_notification_group_messages),
SOCIAL("com.vitorpamplona.amethyst.group.social", R.string.app_notification_group_social),
PAYMENTS("com.vitorpamplona.amethyst.group.payments", R.string.app_notification_group_payments),
CONTENT("com.vitorpamplona.amethyst.group.content", R.string.app_notification_group_content),
DEVELOPER("com.vitorpamplona.amethyst.group.developer", R.string.app_notification_group_developer),
GAMES("com.vitorpamplona.amethyst.group.games", R.string.app_notification_group_games),
}
/**
* The visual + behavioral identity of one notification kind. Each entry owns:
*
* - the [NotificationChannel] it posts on (importance, name, description) — the
* unit Android lets the user silence/customize. Existing channel ids are
* reused verbatim so we never orphan a user's per-channel settings.
* - the accent [color] (`setColor`) and monochrome status-bar [smallIcon] that
* make the kind recognizable in the shade before it's read.
* - the [group] it bundles under (`setGroup`) and the [summaryId] of that
* group's summary notification.
* - the [channelGroup] it sits inside in system settings.
* - the [settingsIcon] rendered next to it in the in-app settings screen.
*
* This replaces the six ad-hoc `getOrCreate*Channel` helpers with one
* table-driven definition every renderer reads from.
*/
enum class NotificationCategory(
@param:StringRes val channelIdRes: Int,
@param:StringRes val channelNameRes: Int,
@param:StringRes val channelDescriptionRes: Int,
@param:StringRes val summaryTextRes: Int,
val importance: Int,
val color: Int,
@param:DrawableRes val smallIcon: Int,
val settingsIcon: MaterialSymbol,
val channelGroup: NotifChannelGroup,
val group: String,
val summaryId: Int,
/** Full-surface color tint — reserved for the highest-signal kinds. */
val colorized: Boolean = false,
) {
DIRECT_MESSAGE(
channelIdRes = R.string.app_notification_dms_channel_id,
channelNameRes = R.string.app_notification_dms_channel_name,
channelDescriptionRes = R.string.app_notification_dms_channel_description,
summaryTextRes = R.string.app_notification_dms_summary,
importance = NotificationManager.IMPORTANCE_HIGH,
color = 0xFF2196F3.toInt(), // blue
smallIcon = R.drawable.ic_notif_message,
settingsIcon = MaterialSymbols.Mail,
channelGroup = NotifChannelGroup.MESSAGES,
group = "com.vitorpamplona.amethyst.DM_NOTIFICATION",
summaryId = 0x10000,
),
REPLY(
channelIdRes = R.string.app_notification_replies_channel_id,
channelNameRes = R.string.app_notification_replies_channel_name,
channelDescriptionRes = R.string.app_notification_replies_channel_description,
summaryTextRes = R.string.app_notification_replies_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF7C4DFF.toInt(), // deep purple
smallIcon = R.drawable.ic_notif_reply,
settingsIcon = MaterialSymbols.Chat,
channelGroup = NotifChannelGroup.SOCIAL,
// Replies group per-thread; renderers override group + summaryId. This
// base is only a fallback when no thread root is known.
group = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION",
summaryId = 0x50000,
),
MENTION(
channelIdRes = R.string.app_notification_mentions_channel_id,
channelNameRes = R.string.app_notification_mentions_channel_name,
channelDescriptionRes = R.string.app_notification_mentions_channel_description,
summaryTextRes = R.string.app_notification_mentions_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF9C27B0.toInt(), // purple
smallIcon = R.drawable.ic_notif_mention,
settingsIcon = MaterialSymbols.AlternateEmail,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION",
summaryId = 0x60000,
),
REACTION(
channelIdRes = R.string.app_notification_reactions_channel_id,
channelNameRes = R.string.app_notification_reactions_channel_name,
channelDescriptionRes = R.string.app_notification_reactions_channel_description,
summaryTextRes = R.string.app_notification_reactions_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFFE91E63.toInt(), // pink / heart
smallIcon = R.drawable.ic_notif_reaction,
settingsIcon = MaterialSymbols.Favorite,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION",
summaryId = 0x40000,
),
REPOST(
channelIdRes = R.string.app_notification_reposts_channel_id,
channelNameRes = R.string.app_notification_reposts_channel_name,
channelDescriptionRes = R.string.app_notification_reposts_channel_description,
summaryTextRes = R.string.app_notification_reposts_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFF4CAF50.toInt(), // green
smallIcon = R.drawable.ic_notif_repost,
settingsIcon = MaterialSymbols.Sync,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.REPOST_NOTIFICATION",
summaryId = 0x70000,
),
ZAP(
channelIdRes = R.string.app_notification_zaps_channel_id,
channelNameRes = R.string.app_notification_zaps_channel_name,
channelDescriptionRes = R.string.app_notification_zaps_channel_description,
summaryTextRes = R.string.app_notification_zaps_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFF7931A.toInt(), // bitcoin orange
smallIcon = R.drawable.ic_notif_zap,
settingsIcon = MaterialSymbols.Bolt,
channelGroup = NotifChannelGroup.PAYMENTS,
group = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION",
summaryId = 0x20000,
colorized = true,
),
MEDIA(
channelIdRes = R.string.app_notification_media_channel_id,
channelNameRes = R.string.app_notification_media_channel_name,
channelDescriptionRes = R.string.app_notification_media_channel_description,
summaryTextRes = R.string.app_notification_media_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF00BCD4.toInt(), // cyan
smallIcon = R.drawable.ic_notif_media,
settingsIcon = MaterialSymbols.Image,
channelGroup = NotifChannelGroup.CONTENT,
group = "com.vitorpamplona.amethyst.MEDIA_NOTIFICATION",
summaryId = 0x80000,
),
ARTICLE(
channelIdRes = R.string.app_notification_articles_channel_id,
channelNameRes = R.string.app_notification_articles_channel_name,
channelDescriptionRes = R.string.app_notification_articles_channel_description,
summaryTextRes = R.string.app_notification_articles_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF3F51B5.toInt(), // indigo
smallIcon = R.drawable.ic_notif_article,
settingsIcon = MaterialSymbols.Description,
channelGroup = NotifChannelGroup.CONTENT,
group = "com.vitorpamplona.amethyst.ARTICLE_NOTIFICATION",
summaryId = 0x90000,
),
CODE(
channelIdRes = R.string.app_notification_code_channel_id,
channelNameRes = R.string.app_notification_code_channel_name,
channelDescriptionRes = R.string.app_notification_code_channel_description,
summaryTextRes = R.string.app_notification_code_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF607D8B.toInt(), // slate
smallIcon = R.drawable.ic_notif_code,
settingsIcon = MaterialSymbols.Code,
channelGroup = NotifChannelGroup.DEVELOPER,
group = "com.vitorpamplona.amethyst.CODE_NOTIFICATION",
summaryId = 0xA0000,
),
BADGE(
channelIdRes = R.string.app_notification_badges_channel_id,
channelNameRes = R.string.app_notification_badges_channel_name,
channelDescriptionRes = R.string.app_notification_badges_channel_description,
summaryTextRes = R.string.app_notification_badges_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFFFC107.toInt(), // amber / gold
smallIcon = R.drawable.ic_notif_badge,
settingsIcon = MaterialSymbols.MilitaryTech,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.BADGE_NOTIFICATION",
summaryId = 0xB0000,
),
CHESS(
channelIdRes = R.string.app_notification_chess_channel_id,
channelNameRes = R.string.app_notification_chess_channel_name,
channelDescriptionRes = R.string.app_notification_chess_channel_description,
summaryTextRes = R.string.app_notification_chess_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF795548.toInt(), // brown
smallIcon = R.drawable.ic_notif_chess,
settingsIcon = MaterialSymbols.ChessKnight,
channelGroup = NotifChannelGroup.GAMES,
group = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION",
summaryId = 0x30000,
),
PAYMENT_RECEIVED(
channelIdRes = R.string.app_notification_payments_channel_id,
channelNameRes = R.string.app_notification_payments_channel_name,
channelDescriptionRes = R.string.app_notification_payments_channel_description,
summaryTextRes = R.string.app_notification_payments_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF16B979.toInt(), // lightning green — distinct from the gold zap channel
smallIcon = R.drawable.ic_notif_zap,
settingsIcon = MaterialSymbols.AccountBalanceWallet,
channelGroup = NotifChannelGroup.PAYMENTS,
group = "com.vitorpamplona.amethyst.PAYMENT_RECEIVED_NOTIFICATION",
summaryId = 0xC0000,
),
;
fun channelId(context: Context): String = stringRes(context, channelIdRes)
@Volatile private var channelEnsured = false
/**
* Creates this category's channel group and channel exactly once per process.
* The `create*` calls are idempotent but each is a Binder IPC, so — like the
* original `getOrCreate*Channel` field-caching — we skip them after the first
* successful creation instead of paying two IPCs on every post and re-render.
* Returns the channel id to post on.
*/
fun ensureChannel(context: Context): String {
val id = channelId(context)
if (channelEnsured) return id
synchronized(this) {
if (!channelEnsured) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannelGroup(
NotificationChannelGroup(channelGroup.id, stringRes(context, channelGroup.nameRes)),
)
val channel =
NotificationChannel(id, stringRes(context, channelNameRes), importance).apply {
description = stringRes(context, channelDescriptionRes)
group = channelGroup.id
}
nm.createNotificationChannel(channel)
channelEnsured = true
}
}
return id
}
}
@@ -28,6 +28,7 @@ import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier
import com.vitorpamplona.amethyst.ui.stringRes
@@ -62,57 +63,36 @@ object NotificationChannels {
val ensure: (Context) -> Unit,
)
/** The per-kind content channels, derived from [NotificationCategory], in a
* sensible settings order, plus the two non-event channels (scheduled posts,
* calls) that don't map to a Nostr event kind. */
val contentChannels: List<Entry> =
listOf(
Entry(
nameRes = R.string.app_notification_dms_channel_name,
icon = MaterialSymbols.Mail,
channelId = { stringRes(it, R.string.app_notification_dms_channel_id) },
ensure = { NotificationUtils.getOrCreateDMChannel(it) },
),
Entry(
nameRes = R.string.app_notification_mentions_channel_name,
icon = MaterialSymbols.AlternateEmail,
channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) },
ensure = { NotificationUtils.getOrCreateMentionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_replies_channel_name,
icon = MaterialSymbols.Chat,
channelId = { stringRes(it, R.string.app_notification_replies_channel_id) },
ensure = { NotificationUtils.getOrCreateReplyChannel(it) },
),
Entry(
nameRes = R.string.app_notification_reactions_channel_name,
icon = MaterialSymbols.Favorite,
channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) },
ensure = { NotificationUtils.getOrCreateReactionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_zaps_channel_name,
icon = MaterialSymbols.Bolt,
channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) },
ensure = { NotificationUtils.getOrCreateZapChannel(it) },
),
Entry(
nameRes = R.string.app_notification_chess_channel_name,
icon = MaterialSymbols.ChessKnight,
channelId = { stringRes(it, R.string.app_notification_chess_channel_id) },
ensure = { NotificationUtils.getOrCreateChessChannel(it) },
),
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
ensure = { AndroidScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
),
)
NotificationCategory.entries
// Chess (NIP-64) is a debug-only feature for now — don't surface (or
// create) its channel in release settings.
.filter { it != NotificationCategory.CHESS || isDebug }
.map { category ->
Entry(
nameRes = category.channelNameRes,
icon = category.settingsIcon,
channelId = { category.channelId(it) },
ensure = { category.ensureChannel(it) },
)
} +
listOf(
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
ensure = { AndroidScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
),
)
fun statusOf(
context: Context,
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
/**
* Content-extraction helpers shared by the per-kind notification renderers:
* decrypting private payloads, pulling a clean one-line excerpt, and resolving
* the media URL to show as a notification's big picture.
*/
object NotificationContent {
/** First non-blank line of [content], trimmed to [max] chars, or "" if none. */
fun excerpt(
content: String?,
max: Int = 280,
): String =
content
?.split("\n")
?.firstOrNull { it.isNotBlank() }
?.take(max)
?: ""
/**
* The result of [resolveMentions]: the excerpt with every `nostr:npub` /
* `nostr:nprofile` token swapped for the cited user's `@DisplayName`, plus the
* [User]s that were cited so a renderer can add them to its enrichment window
* and re-render as their metadata loads.
*/
data class ResolvedText(
val text: String,
val citedUsers: List<User>,
)
/**
* Like [excerpt], but rewrites inline user mentions to readable names: each
* `nostr:npub1…` / `nostr:nprofile1…` (optionally `@`-prefixed) becomes
* `@<best display name>` for the cited user, and that user is returned in
* [ResolvedText.citedUsers]. Event/address references (`nevent`, `note`,
* `naddr`, …) are left untouched — they aren't people and have no name to show.
*
* Called from the build closure on every re-render, so as a cited user's
* kind:0 arrives the notification text updates in place from `@npub1abc…` to
* `@RealName`.
*/
fun resolveMentions(
content: String?,
max: Int = 280,
): ResolvedText {
val line =
content
?.split("\n")
?.firstOrNull { it.isNotBlank() }
?: return ResolvedText("", emptyList())
// Cheap opt-out: no mention tokens means no work and no allocations.
if (!line.contains("npub1", ignoreCase = true) && !line.contains("nprofile1", ignoreCase = true)) {
return ResolvedText(line.take(max), emptyList())
}
val cited = mutableListOf<User>()
val rewritten =
Nip19Parser.nip19regex.replace(line) { match ->
val type = match.groups[3]?.value ?: match.groups[5]?.value
val key = match.groups[4]?.value ?: match.groups[6]?.value
val trailing = match.groups[7]?.value ?: ""
val hex =
when (val entity = Nip19Parser.parseComponents(type ?: "", key, null)?.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
if (hex != null) {
val user = LocalCache.getOrCreateUser(hex)
cited.add(user)
"@${user.toBestDisplayName()}$trailing"
} else {
// nevent / note / naddr / parse failure — leave as-is.
match.value
}
}
return ResolvedText(rewritten.take(max), cited)
}
/**
* The result of [renderNoteText]: [ResolvedText] plus [imageUrl], the first
* inline image link found anywhere in the note. When present, a renderer shows
* it as the notification's big picture and the link is stripped from [text] —
* so a mention whose body is "look at this https://…/cat.jpg" shows the photo
* instead of the raw URL.
*/
data class RenderedText(
val text: String,
val citedUsers: List<User>,
val imageUrl: String?,
)
private val whitespace = Regex("\\s+")
private val trailingPunctuation = charArrayOf('.', ',', ')', '(', '!', '?', ';', ':', '"', '\'')
/**
* The first `http(s)` image URL in [content] (anywhere, not only the first
* line — images are usually on their own line), or null. Trailing sentence
* punctuation is trimmed so "…/cat.jpg." still resolves. Videos are ignored:
* Coil can't load them as a still, so their link stays in the text.
*/
fun firstImageUrl(content: String?): String? {
if (content == null) return null
content.split(whitespace).forEach { raw ->
if (!raw.startsWith("http://", ignoreCase = true) && !raw.startsWith("https://", ignoreCase = true)) {
return@forEach
}
val url = raw.trimEnd(*trailingPunctuation)
if (RichTextParser.isImageUrl(url)) return url
}
return null
}
/**
* [resolveMentions] plus inline-image extraction: resolves `@npub` mentions,
* pulls the first image link out as [RenderedText.imageUrl], and removes that
* link from the excerpt text (collapsing the whitespace it leaves behind).
*/
fun renderNoteText(
content: String?,
max: Int = 280,
): RenderedText {
val resolved = resolveMentions(content, max = Int.MAX_VALUE)
val imageUrl = firstImageUrl(content)
val text =
if (imageUrl != null) {
resolved.text
.replace(imageUrl, "")
.replace(whitespace, " ")
.trim()
.take(max)
} else {
resolved.text.take(max)
}
return RenderedText(text, resolved.citedUsers, imageUrl)
}
suspend fun decryptZapContentAuthor(
event: LnZapRequestEvent,
signer: NostrSigner,
): Event? =
if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) {
signer.decryptZapEvent(event)
} else {
event
}
suspend fun decryptContent(
note: Note,
signer: NostrSigner,
): String? =
when (val event = note.event) {
is PrivateDmEvent -> event.decryptContent(signer)
is LnZapRequestEvent -> decryptZapContentAuthor(event, signer)?.content
else -> event?.content
}
/**
* The primary image/thumbnail URL to render as a notification's big picture,
* or null if the event carries no displayable media. Pictures use their first
* imeta url; videos prefer the poster-frame `image`, falling back to the video
* url only when no poster is present.
*/
fun mediaImageUrl(event: Event?): String? =
when (event) {
is PictureEvent -> event.imetaTags().firstOrNull()?.url
is VideoEvent -> {
val meta = event.imetaTags().firstOrNull()
meta?.image?.firstOrNull() ?: meta?.url
}
else -> null
}
}
@@ -24,7 +24,9 @@ import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.renderers.BuzzDmNotification
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -34,6 +36,8 @@ import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -41,8 +45,12 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -53,6 +61,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.Log
@@ -101,11 +110,16 @@ class NotificationDispatcher(
// Direct-arrival
PrivateDmEvent.KIND,
LnZapEvent.KIND,
NutzapEvent.KIND,
OnchainZapEvent.KIND,
Bolt12ZapEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
BadgeAwardEvent.KIND,
TextNoteEvent.KIND,
CommentEvent.KIND,
// Public content kinds — routed to the Mentions channel when p-tagged.
// Public content kinds — routed to their channel when p-tagged.
PictureEvent.KIND,
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
@@ -115,12 +129,19 @@ class NotificationDispatcher(
PollEvent.KIND,
GitPatchEvent.KIND,
GitIssueEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
HighlightEvent.KIND,
LongTextNoteEvent.KIND,
WikiNoteEvent.KIND,
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
WakeUpEvent.KIND,
// Buzz DM messages in a `t=dm` relay-group channel (participant-
// routed; gated to Buzz DMs only in the predicate so ordinary
// kind-9 chats — Concord/NIP-C7 — don't leak into the tray).
StreamMessageV2Event.KIND,
ChatEvent.KIND,
// Unwrapped from GiftWrap → Seal
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
@@ -204,6 +225,25 @@ class NotificationDispatcher(
// tagsAnEventByUser's replyTo check would otherwise
// always miss for replies into addressable posts.
val note = LocalCache.getNoteIfExists(event) ?: return@predicate false
// Buzz DM messages (kind 40002 / kind 9 in a `t=dm`
// relay-group channel) are participant-routed, not
// p-tagged. Gate them EXCLUSIVELY on Buzz-DM
// membership so ordinary kind-9 chats (Concord /
// NIP-C7) don't fall through the generic gate below.
if (event is StreamMessageV2Event || event is ChatEvent) {
return@predicate pubkeys.any { pubkey ->
BuzzDmNotification.buzzDmChannelForMe(note, pubkey) != null
}
}
// Reactions/reposts are routed by tagsAnEventByUser
// (the reacted note's author == me), not by a `p`
// tag — so a Buzz-style bare `["e", id]` like, which
// carries no `p` tag, still notifies. tagsAnEventByUser
// below keeps it scoped to reactions on MY note.
val isReactionOrRepost =
event is ReactionEvent || event is RepostEvent || event is GenericRepostEvent
pubkeys.any { pubkey ->
// Public chat replies into my own messages often
// omit the `p` tag; relax the gate for them (the
@@ -211,6 +251,7 @@ class NotificationDispatcher(
// messages actually replying to me).
(
event.isTaggedUser(pubkey) ||
isReactionOrRepost ||
NotificationFeedFilter.isNotifiablePublicChatReply(note, pubkey)
) &&
NotificationFeedFilter.tagsAnEventByUser(note, pubkey)
@@ -0,0 +1,222 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import android.os.PowerManager
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
/**
* Makes a tray notification *observable*: it renders immediately from whatever
* is already in [com.vitorpamplona.amethyst.model.LocalCache], then — if the
* involved users' names/pictures or the involved notes' content/media haven't
* loaded yet — opens a bounded relay window, subscribes to those users and
* notes, and re-renders the same notification (replacing it in place) as the
* missing data arrives.
*
* This is the generalized form of the ad-hoc subscribe-and-wait that
* `notifyIncomingCall` and `wakeUpFor` do: on a cold push the process has no
* cached metadata, so without this a notification would show a raw pubkey and
* no avatar. With it, the notification fills in the moment the kind:0 (and the
* post itself, for media) lands.
*
* The re-render path relies on notifications being keyed by a stable id and on
* `setOnlyAlertOnce(true)` (see [PushNotifier]) so replacements update silently
* instead of re-buzzing.
*/
object NotificationEnricher {
private const val TAG = "NotificationEnricher"
private const val WINDOW_MS = 25_000L
/** Collapses the StateFlow initial-value burst and rapid relay arrivals into
* one re-render, instead of rebuilding (reloading avatars, re-posting) on
* every emission. */
private const val RENDER_DEBOUNCE_MS = 250L
/** Caps concurrent enrichment windows so a push burst can't hold N
* simultaneous relay windows + wakelocks + subscriptions. */
private const val MAX_CONCURRENT_WINDOWS = 4
private val windowLimiter = Semaphore(MAX_CONCURRENT_WINDOWS)
/**
* Posts [build] now, then observes [users] and [notes] for the enrichment
* window, re-running [build] whenever their metadata/content changes, until
* [isComplete] reports everything needed is present or the window elapses.
*
* Non-blocking: the observation runs detached on the app IO scope under its
* own wakelock, so the caller (the notification dispatcher) is never held up.
* When [isComplete] is already satisfied, no relay window is opened.
*/
fun enrichAndPost(
context: Context,
account: Account,
notificationId: String,
users: Collection<User>,
notes: Collection<Note>,
isComplete: () -> Boolean,
build: suspend () -> Unit,
) {
Amethyst.instance.applicationIOScope.launch {
withEnrichmentWakeLock(context) {
// 1. Immediate render from cache — under the wakelock so a
// cold-push post can't be lost to Doze before it reaches the
// tray (the parent dispatcher wakelock is already released).
runBuild(build)
// 2. If we already have everything, we're done — no relay window.
if (isComplete()) return@withEnrichmentWakeLock
// 3. Bound concurrent relay windows. When the cap is hit the
// cached render still stands; it just won't live-enrich.
if (!windowLimiter.tryAcquire()) return@withEnrichmentWakeLock
try {
observeUntilComplete(context, notificationId, account, users, notes, isComplete, build)
} finally {
windowLimiter.release()
}
}
}
}
@OptIn(FlowPreview::class)
private suspend fun observeUntilComplete(
context: Context,
notificationId: String,
account: Account,
users: Collection<User>,
notes: Collection<Note>,
isComplete: () -> Boolean,
build: suspend () -> Unit,
) {
val userSubs = users.map { UserFinderQueryState(it, account) }
val noteSubs = notes.map { EventFinderQueryState(it, account) }
val authSub = ScreenAuthAccount(account)
try {
Amethyst.instance.authCoordinator.subscribe(authSub)
userSubs.forEach {
Amethyst.instance.sources.userFinder
.subscribe(it)
}
noteSubs.forEach {
Amethyst.instance.sources.eventFinder
.subscribe(it)
}
coroutineScope {
// Keep the relay pool connected for the duration of the window.
val relayJob =
launch {
try {
withTimeout(WINDOW_MS) {
Amethyst.instance.relayProxyClientConnector.relayServices
.collect()
}
} catch (_: CancellationException) {
// window elapsed or observation finished first
}
}
// Re-render on metadata/content changes. Debounced so the
// StateFlow initial-value burst and rapid arrivals collapse into
// one rebuild. Stops as soon as everything needed is present, or
// the user dismissed the notification in-app.
val changes =
users.map { it.metadata().flow.map { } } +
notes.map {
it
.flow()
.metadata.stateFlow
.map { }
}
if (changes.isNotEmpty()) {
withTimeoutOrNull(WINDOW_MS) {
merge(*changes.toTypedArray())
.debounce(RENDER_DEBOUNCE_MS)
.onEach { runBuild(build) }
.takeWhile { !NotificationUtils.wasDismissed(notificationId) && !isComplete() }
.collect { }
}
}
relayJob.cancel()
}
} finally {
noteSubs.forEach {
Amethyst.instance.sources.eventFinder
.unsubscribe(it)
}
userSubs.forEach {
Amethyst.instance.sources.userFinder
.unsubscribe(it)
}
Amethyst.instance.authCoordinator.unsubscribe(authSub)
}
}
private suspend fun runBuild(build: suspend () -> Unit) {
try {
build()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Notification build failed", e)
}
}
private inline fun <T> withEnrichmentWakeLock(
context: Context,
block: () -> T,
): T {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakeLock =
powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"amethyst:notification_enrichment",
)
wakeLock.acquire(WINDOW_MS + 5_000L)
try {
return block()
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}
}
@@ -36,6 +36,7 @@ import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -79,6 +80,10 @@ class NotificationRelayService : Service() {
companion object {
private const val TAG = "NotificationRelayService"
private const val CHANNEL_ID = "notification_relay_service"
/** Parsed back into `Route.ActiveSubscriptions` by `MainActivity.uriToRoute`. */
private const val ACTIVE_SUBSCRIPTIONS_URI = "activesubs"
private const val NOTIFICATION_ID = 9832
private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE"
@@ -141,6 +146,9 @@ class NotificationRelayService : Service() {
private var relayServiceCollectorJob: Job? = null
private var connectedRelayCount = 0
/** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */
private var lastBreakdown: List<String> = emptyList()
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
@@ -336,9 +344,12 @@ class NotificationRelayService : Service() {
pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays)
}
// Tapping goes to the screen that answers the question the notification raises — "why is it
// connected to N relays" — rather than to whatever tab was last open.
val openAppIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
data = ACTIVE_SUBSCRIPTIONS_URI.toUri()
}
val pendingIntent =
PendingIntent.getActivity(
@@ -348,11 +359,34 @@ class NotificationRelayService : Service() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// Expanded only. The collapsed line stays the bare count it has always been — that is all
// most people want from an ongoing notification — and the per-job breakdown appears solely
// when someone deliberately expands it to ask why the phone is talking to N relays.
//
// Held across reconnects rather than recomputed blindly: the breakdown is derived from the
// *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and
// the expanded view would collapse to a single line exactly when someone is most likely
// looking at it. What each connection is *for* does not change while it is re-establishing,
// so the last known answer is still the right one; only the count above it goes stale, and
// that count is already labelled "connecting".
val fresh = RelayPurposeSummary.lines(this)
if (fresh.isNotEmpty()) lastBreakdown = fresh
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.always_on_notif_title))
.setContentText(contentText)
.setSmallIcon(R.drawable.amethyst_service)
.apply {
breakdown?.let {
setStyle(
NotificationCompat
.BigTextStyle()
.setBigContentTitle(getString(R.string.always_on_notif_title))
.bigText(contentText + "\n\n" + it.joinToString("\n")),
)
}
}.setSmallIcon(R.drawable.amethyst_service)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
@@ -30,11 +30,14 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip10Notes.tags.notify
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip22Comments.notify
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
@@ -199,6 +202,14 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return
// Resolve @/nostr: mentions typed into the notification reply, so a cited member is tagged
// (`p`) and linkable — the same enrichment the in-app composers do. The comment builders
// already tag the reply-parent author, so drop it from the body mentions to avoid a
// duplicate `p` (kind-1 doesn't auto-tag the parent, so nothing is lost there).
val tagger = NewMessageTagger(replyText, dao = LocalCache)
tagger.run()
val mentions = tagger.pTags?.mapNotNull { pt -> pt.pubkeyHex.takeIf { it != targetEvent.pubKey } }.orEmpty()
val template =
when {
// A brand-new Amethyst kind-1 thread root is replied to with a NIP-22
@@ -207,25 +218,31 @@ class NotificationReplyReceiver : BroadcastReceiver() {
targetEvent.isNewThread() &&
targetEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) -> {
CommentEvent.replyBuilder(
msg = replyText,
msg = tagger.message,
replyingTo = EventHintBundle(targetEvent),
)
) {
notify(mentions.map { PTag(it) })
}
}
targetEvent is TextNoteEvent -> {
TextNoteEvent.build(
note = replyText,
note = tagger.message,
replyingTo = EventHintBundle(targetEvent),
)
) {
notify(mentions.map { PTag(it) })
}
}
else -> {
// NIP-22 CommentEvent and other non-threaded events (e.g. long-form articles)
// both reply via NIP-22 comments.
CommentEvent.replyBuilder(
msg = replyText,
msg = tagger.message,
replyingTo = EventHintBundle(targetEvent),
)
) {
notify(mentions.map { PTag(it) })
}
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
/** Deep-link URIs consumed by `MainActivity.uriToRoute` when a notification is tapped. */
object NotificationRoutes {
private const val ACCOUNT = "?account="
private const val SCROLL_TO = "&scrollTo="
fun accountNpub(account: Account): String =
account.signer.pubKey
.hexToByteArray()
.toNpub()
/** Opens the note directly (used for replies, mentions, DMs, media, git). */
fun noteUri(
note: Note,
accountNpub: String,
): String = note.toNEvent() + ACCOUNT + accountNpub
/** Opens the Notifications tab, scrolled to [scrollToId] (used for zaps, reactions, chess). */
fun notificationsUri(
accountNpub: String,
scrollToId: String,
): String = "notifications$ACCOUNT$accountNpub$SCROLL_TO$scrollToId"
/** Opens a Marmot group chatroom (welcome + group message). */
fun marmotUri(
nostrGroupId: String,
accountNpub: String,
): String = "marmot:$nostrGroupId$ACCOUNT$accountNpub"
/**
* Opens a NIP-29 relay-group / Buzz chatroom. [channelNAddr] is the channel's
* kind-39000 naddr, which `MainActivity.uriToRoute` already routes to
* `Route.RelayGroup` via the standard nostr-entity path.
*/
fun relayGroupUri(
channelNAddr: String,
accountNpub: String,
): String = "$channelNAddr$ACCOUNT$accountNpub"
}
internal fun Context.notificationManager(): NotificationManager = ContextCompat.getSystemService(this, NotificationManager::class.java) as NotificationManager
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.renderers.NwcPaymentNotifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
/**
* Posts tray notifications for non-zap Lightning payments reported by the logged-in
* account's connected NWC wallets.
*
* The relay subscription that receives these events is NOT here — it lives in
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager],
* grouped with the account's always-on zap/notification inbox subscriptions so it
* shares their lifecycle (open while logged in, warm in the background). That
* manager decrypts each notification and publishes non-zap payments to
* `NwcSignerState.incomingNonZapPayments`; this class is only the Context-bound
* bridge that drains that flow into an OS notification.
*
* Keeping decode and display decoupled means the flow stays populated even when OS
* notifications are denied (a future in-app Notifications-tab consumer can drain
* the same flow); [NwcPaymentNotifier] itself is what no-ops when the tray is off.
*/
class NwcPaymentNotificationWatcher(
private val context: Context,
private val scope: CoroutineScope,
private val accountFlow: Flow<Account?>,
) {
fun start() {
scope.launch(Dispatchers.IO) {
accountFlow
.distinctUntilChanged { a, b -> a?.signer?.pubKey == b?.signer?.pubKey }
.collectLatest { account ->
account ?: return@collectLatest
account.nip47SignerState.incomingNonZapPayments.collect { tx ->
NwcPaymentNotifier.notify(context, account, tx)
}
}
}
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* The per-job breakdown behind the always-on notification's relay count.
*
* **Only ever shown expanded.** The collapsed line stays exactly what it was — a count — because
* that is all most people ever want from an ongoing notification. This is for the moment someone
* taps to ask *why* their phone is talking to 40 relays.
*
* A relay usually serves several jobs at once (measured: a typical relay carries four), so these
* counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays
* carry my DMs", not "how is the pool partitioned" — there is no partition.
*
* Purposes with no label — feeds and whatever screen is open — collapse into one "browsing" line.
* Those disappear on their own once the app is backgrounded, which is exactly when this notification
* matters most, so spelling them out would add noise precisely when the user is least interested.
*/
object RelayPurposeSummary {
/**
* Lines for the expanded notification, busiest first. Empty when nothing is attributed yet —
* the caller must then fall back to the bare count rather than render an empty section.
*/
fun lines(ctx: Context): List<String> {
val client = Amethyst.instance.client
val named = mutableMapOf<SubPurpose, MutableSet<NormalizedRelayUrl>>()
val browsing = mutableSetOf<NormalizedRelayUrl>()
client.connectedRelaysFlow().value.forEach { relay ->
client
.activeRequests(relay)
.values
.flatten()
.purposes()
.forEach { purpose ->
if (SubPurposeLabels.isWorthNamingInNotification(purpose)) {
named.getOrPut(purpose) { mutableSetOf() }.add(relay)
} else {
browsing.add(relay)
}
}
}
val lines =
named.entries
.sortedWith(compareByDescending<Map.Entry<SubPurpose, Set<NormalizedRelayUrl>>> { it.value.size }.thenBy { it.key.name })
.map { (purpose, relays) ->
pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size)
}.toMutableList()
if (browsing.isNotEmpty()) {
lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size))
}
return lines
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
/**
* Article & highlight notifications — long-form (kind 30023), wiki (30818), and
* NIP-84 highlights (9802) that mention or highlight your writing. Rendered as an
* indigo card. Highlights show the highlighted passage; long-form/wiki mentions
* show the excerpt. Author name + avatar enriched observably.
*/
object ArticleNotification {
suspend fun notify(
context: Context,
account: Account,
event: Event,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(note)) return
val author = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(note, accountNpub)
val isHighlight = event is HighlightEvent
val titleRes =
if (isHighlight) {
R.string.app_notification_articles_channel_message_highlight
} else {
R.string.app_notification_articles_channel_message
}
val bodySource = if (event is HighlightEvent) event.quote() else event.content
val rendered = NotificationContent.renderNoteText(bodySource)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + rendered.citedUsers,
notes = listOf(note),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
rendered.citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val body = NotificationContent.renderNoteText(bodySource)
nm.postStandard(
category = NotificationCategory.ARTICLE,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
bigPictureUrl = body.imageUrl,
)
}
}
}

Some files were not shown because too many files have changed in this diff Show More