Compare commits

...
Author SHA1 Message Date
Claude 92ca11a583 chore: move stray NIP-29 section comment to AccountRelayGroupActions
The relay-group section header and joinRelayGroup KDoc were left
dangling at the end of AccountConcordActions when the clusters were
split into separate files; reattach them to the function they describe.
Found by the post-refactor audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:27:56 +00:00
Claude dc5e4562fd fix: restore two Marmot log messages mangled during extraction
The account-qualification regex in the AccountMarmotActions extraction
also rewrote 'marmotManager is NULL' to 'account.marmotManager is NULL'
inside two log string literals, changing log output text. Restore the
original wording. Found by the post-refactor equivalence audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:22:31 +00:00
Claude 1dba215d4d refactor: extract AccountZapActions from Account
Moves the ~270-line zap/payment orchestration (NIP-57 zap requests,
NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC
onchain zaps/sends/splits) into AccountZapActions, exposed as
account.zaps. The onchain backend-not-configured constant moves with
it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet
viewmodels, blossom payments, app functions) now call account.zaps.*
directly. Moved code is unchanged except for account. qualification.

Completes the Account decoupling series: Account.kt went from 6228 to
3618 lines across EventBroadcaster, AccountConcordActions,
AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:16:51 +00:00
Claude d2c9593919 refactor: extract AccountRelayGroupActions from Account
Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration
(join/leave/create/delete/archive, threads, invites, pins, member/role
management, metadata edits, Buzz DMs/jobs/workflows/typing,
community member add/remove) into AccountRelayGroupActions, exposed as
account.relayGroups. External callers now use account.relayGroups.*
directly. Moved code is unchanged except for account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:11:04 +00:00
Claude 20149e2600 refactor: extract AccountMarmotActions from Account
Moves the ~540-line Marmot/MLS orchestration cluster (group create/
leave/reset, member add/remove via key-package fetch, admin grant/
revoke, metadata updates, group messaging, key-package publishing and
relay resolution) into AccountMarmotActions, exposed as account.marmot.
External callers (marmot group screens, AccountViewModel forwarders,
NotificationReplyReceiver, DecryptAndIndexProcessor) now call
account.marmot.* directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:07:24 +00:00
Claude c0932e0330 refactor: extract AccountConcordActions from Account
Moves the ~1,000-line Concord orchestration cluster (join/create/invite
flows, channel messages/reactions/edits/typing, roles and moderation,
refound/rekey/stranded-recovery, metadata + channel management,
control-plane sync) into AccountConcordActions, exposed as
account.concord. The two Concord file-level constants move with it.

Rumor ingestion (consumeConcordRumorGated, refreshConcordChannelIndex)
stays on Account since ConcordSessionManager is constructed with it,
as do the cross-feature sendMinichatReply and the read-path
isConcordBanned policy. External callers (Concord screens,
AccountViewModel forwarders, note action menus) now call
account.concord.* directly - no delegating shims.

Moved code is unchanged except for account. qualification.
Account.kt: 6228 -> 4935 lines so far in this series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:05:00 +00:00
Claude 7936cab9d5 refactor: extract EventBroadcaster from Account
Moves the sign-and-publish choke point out of Account into an
EventBroadcaster class: relay-set computation (outbox model, hints,
channel home relays, broadcast lists, DM inboxes, the recursive
linked-event descent) plus every publish path (sendAutomatic,
sendMyPublicAndPrivateOutbox, sendLiterallyEverywhere, broadcast,
signAndSendPrivately*, signAndComputeBroadcast,
signAnonymouslyAndBroadcast, republishEventsTo).

Account keeps one-line delegates so its 85+ internal call sites and all
external callers are unchanged; upcoming Account*Actions extractions
will call the broadcaster directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:54:52 +00:00
Claude 5311efe65e refactor: extract CachePruner and CacheSearch from LocalCache; move Dao out of ui
Two read/reclaim policy clusters leave the LocalCache god object into
sibling classes in the same package, each taking the cache as its only
constructor dependency so the policies are testable in isolation:

- CachePruner: cleanMemory/cleanObservers, the six prune passes
  (hidden/old/expired/superseded/replies+reactions), and the shared
  unlinkAndRemove removal primitive (with removeIfWrap and
  editedTargetIdOf). LocalCache.deleteNote and
  DecryptAndIndexProcessor now call pruner.unlinkAndRemove;
  MemoryTrimmingService drives cache.pruner.*.
  refreshDeletedNoteObservers becomes internal so the pruner can
  notify observers.

- CacheSearch: findUsersStartingWith(username, account),
  findNotesStartingWith, and the three channel prefix searches, plus
  their private exclusion rules. Callers (SearchBarViewModel,
  AgentAttestationScreen, UserSuggestionState, BuzzNewDmViewModel) use
  cache.search.* directly - no delegating shims left behind.

Also moves the Dao interface out of ui/actions/NewMessageTagger.kt into
the model package where its implementor (LocalCache) and its types
live, removing a model-layer interface defined in a UI file.

All moved code is unchanged except for cache. qualification; behavior
is identical. LocalCache.kt: 4554 -> 3921 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:48:21 +00:00
Claude 532b9e67fe refactor: collapse LocalCache event dispatch into grouped when branches
justConsumeInnerInner was one when(event) with ~290 branches, of which
172 were identical single-call bodies routing to consumeBaseReplaceable
or consumeRegularEvent, and ~55 more were single-line Buzz consumer
calls. Since all four shared consumers take a plain Event, the
boilerplate branches are now comma-grouped into one branch per
consumer (replaceable/addressable, regular, Buzz timeline, Buzz
store-only), keeping every branch with per-kind logic exactly as it
was.

Dispatch is provably unchanged: none of the 289 event classes has a
supertype among the classes in any other branch group, so reordering
cannot shadow a branch, and the old and new type-to-consumer mappings
were compared exhaustively and are identical. The else branch still
rejects unlisted kinds, preserving the supported-kinds allowlist.

LocalCache.kt: 5155 -> 4554 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:29:08 +00:00
Vitor PamplonaandGitHub 3823eae11d Merge pull request #3840 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 09:54:16 -04:00
vitorpamplonaandgithub-actions[bot] 9cb17a26e8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 13:51:50 +00:00
Vitor PamplonaandGitHub 6d518adddb Merge pull request #3841 from vitorpamplona/feat/observer-out-of-band
Let a monitor publish what it learned without dialling
2026-08-01 09:49:03 -04:00
Vitor PamplonaandClaude Opus 5 c3c20c6615 Let a monitor publish what it learned without dialling
RelayObserver is a RelayConnectionListener, so on its own it can only
report on relays something opened a websocket to. On a large fan-out that
is a small minority, and it is the wrong minority: the cheap checks that
decide NOT to dial — a TCP probe, a DNS failure, a host struck out after
repeated silence — are precisely the ones that learn a relay is gone, and
their findings had nowhere to go.

Measured on a 16,507-relay list: 104 records published. Everything else
was ruled out before the client ever saw it, so the monitor had nothing
to say about 99% of the relays it had just formed an opinion on.

record() takes those findings. Same rules as the connection path — a
relay that answered is not demoted by one failed probe, and a reachable
relay with no measured time is published with no time rather than a
fabricated zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:42:00 -04:00
David KasparandGitHub da009f36bd Merge pull request #3839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 12:07:57 +02:00
davotoulaandgithub-actions[bot] f7959571a7 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 09:31:43 +00:00
davotoula 529c114802 fix: hoist the playback-error test fixtures out of composition 2026-08-01 11:21:47 +02:00
Vitor PamplonaandGitHub 2741d32cfd Merge pull request #3838 from vitorpamplona/claude/nip42-auth-dm-delivery-test-fhn7fd
Fix InProcessWebSocket race condition in connect-time AUTH delivery
2026-08-01 01:29:24 -04:00
Claude ae61e69136 fix(relays): deliver in-process server frames only after onOpen
Nip42AuthDmDeliveryTest stalled for its full 10s timeout on CI while
passing locally. The stall is a race in InProcessWebSocket.connect():
server.connect() runs the session's connect-time policies synchronously,
so FullAuthPolicy's AUTH challenge reached the client's listener before
the socket assigned its `incoming` channel and before onOpen fired —
breaking the WebSocketListener contract (no onMessage before onOpen).

RelayAuthenticator answers that challenge on its own coroutine. When the
signed AUTH reply hit send() before the connect thread reached the
`incoming` assignment, send() returned false and the reply was silently
dropped. Nothing recovers from that: the challenge is already dedup'd as
answered, and an EVENT rejected with OK-false `auth-required:` never
re-triggers auth (only a CLOSED does), so the pending gift wrap was
never resent — exactly the CI signature (10.011s, no auth activity
between the authenticator's Init and Destroy logs).

Server->client frames now go through an outbound channel drained by a
coroutine started only after onOpen, so every connect-time frame reaches
the listener with the socket fully wired. Order is preserved by the
single drainer, same as the existing inbound path.

Both new InProcessWebSocketTest cases fail deterministically without the
reorder (the challenge always outran onOpen; a reply sent from the first
onMessage was always rejected) and pass with it, on top of the full
:geode:test and :quartz:jvmTest suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bf1Y91sfjxwi2ig4ymGTA9
2026-08-01 05:27:51 +00:00
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
68 changed files with 5784 additions and 4580 deletions
+5 -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
@@ -126,7 +126,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
@@ -161,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
@@ -220,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
+11 -11
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
@@ -285,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 }}
@@ -337,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
@@ -536,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 }}
@@ -586,7 +586,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
@@ -777,7 +777,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 }}
@@ -831,17 +831,17 @@ jobs:
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v3
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@v6
uses: docker/build-push-action@v7
with:
context: .
file: geode/Dockerfile
@@ -866,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
@@ -1010,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
@@ -66,28 +66,37 @@ class PlaybackErrorOverlayFitTest {
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
/**
* Built outside composition on purpose: the mock and its error state are fixtures for the whole
* test, not per-composition state. Creating them inside `setContent` would rebuild both on every
* recomposition (and trips Compose's UnrememberedMutableState lint).
*/
private fun failedControllerState() =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
)
private fun renderInBox(
width: Dp,
height: Dp,
fontScale: Float = 1f,
) {
val controllerState = failedControllerState()
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,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
@@ -151,24 +160,14 @@ class PlaybackErrorOverlayFitTest {
// 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)
val controllerState = failedControllerState()
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,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
@@ -196,24 +195,14 @@ class PlaybackErrorOverlayFitTest {
// 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)
val controllerState = failedControllerState()
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,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
/*
* 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.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import kotlin.coroutines.cancellation.CancellationException
/**
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
* metadata updates, group messaging, and key-package publishing. MLS state
* lives in [MarmotManager]; this class wires it to the account's signer, relay
* client, and relay lists. Functions live here (not a ViewModel) so headless
* callers - notification receivers, background workers - can drive them.
*/
class AccountMarmotActions(
private val account: Account,
) {
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
* canonical set; fall back to the account's outbox relays if the group
* has none (e.g. a group joined before MIP-01 metadata existed).
*
* Lives on Account (not AccountViewModel) so that headless callers —
* notifications' BroadcastReceiver, background workers — can resolve
* relays without spinning up a ViewModel.
*/
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
account.marmotManager
?.groupMetadata(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet()
return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value
}
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
*/
suspend fun sendMarmotGroupMessage(
nostrGroupId: HexKey,
innerEvent: Event,
groupRelays: Set<NormalizedRelayUrl>,
) {
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}" +
"${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent)
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
// Link the envelope to the inner message we just encrypted so relay
// OK acceptances drill down to the note the chat renders (see
// LocalCache.addRelayToNoteAndInners).
outbound.signedEvent.innerEventId = innerEvent.id
account.cache.justConsumeMyOwnEvent(outbound.signedEvent)
// Sending a message moves the group out of "New Requests" into
// "Known" — do this eagerly before relay round-trip so the UI
// updates immediately.
account.marmotGroupList.markAsKnown(nostrGroupId)
if (groupRelays.isEmpty()) {
Log.w("MarmotDbg") {
"sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped"
}
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Fetch a user's KeyPackage from relays and add them to a Marmot group.
* Returns a status message describing the outcome.
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
suspend fun fetchKeyPackageAndAddMember(
nostrGroupId: HexKey,
memberPubKey: HexKey,
): String {
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}"
}
val manager = account.marmotManager ?: return "Error: Marmot not initialized"
if (!account.isWriteable()) return "Error: Account is read-only"
// Per MIP-00, invitees advertise the relays that host their
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
// there first, then fall back to the invitee's NIP-65 outbox
// (where KeyPackages typically also land), and finally union
// with our own outbox so we still find packages that ended up
// on a shared relay.
val myOutbox = account.outboxRelays.flow.value
val memberKeyPackageRelays =
(
account.cache
.getAddressableNoteIfExists(
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
.createAddress(memberPubKey),
)?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
)?.relays()?.toSet().orEmpty()
val memberOutbox =
account.cache
.getOrCreateUser(memberPubKey)
.outboxRelays()
?.toSet()
.orEmpty()
val fetchRelays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
}
val event =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(account.client, memberPubKey, fetchRelays)
if (event == null) {
Log.w("MarmotDbg") {
"fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)"
}
return "Error: No KeyPackage found for this user. They may not have published one yet."
}
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}"
}
val keyPackageBase64 = event.keyPackageBase64()
if (keyPackageBase64.isBlank()) {
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
return "Error: KeyPackage event has empty content"
}
// The relays embedded in the WelcomeEvent tell the new member
// where to subscribe for subsequent GroupEvents. Use our own
// outbox — that's where we will publish them.
val groupRelays = myOutbox.toList()
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}"
}
addMarmotGroupMember(
nostrGroupId = nostrGroupId,
keyPackageEvent = event,
groupRelays = groupRelays,
)
return "Success: Member added to group"
}
/**
* Add a member to a Marmot MLS group.
* Publishes the commit GroupEvent, then sends the Welcome gift wrap.
*/
suspend fun addMarmotGroupMember(
nostrGroupId: HexKey,
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
groupRelays: List<NormalizedRelayUrl>,
) {
val memberPubKey = keyPackageEvent.pubKey
Log.d("MarmotDbg") {
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}" +
"groupRelays=${groupRelays.size}"
}
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val (commitEvent, welcomeDelivery) =
manager.addMember(
nostrGroupId = nostrGroupId,
keyPackageEvent = keyPackageEvent,
relays = groupRelays,
)
// The MLS commit has already been applied to the local group state —
// surface the new member list in the chatroom now so observers (e.g.
// MarmotGroupInfoScreen) update without waiting for our own commit to
// loop back through the relay.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}" +
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
}
// Publish commit first (critical ordering)
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(commitEvent.signedEvent, groupRelays.toSet())
// Then send the Welcome gift wrap to the new member.
//
// Use the same delivery path that NIP-17 DMs (kind:1059) take —
// computeRelayListToBroadcast() — which has fallbacks for kind:10050
// → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the
// invitee, so this path is the one we know works. We also union
// with our own outbox + the recipient's dmInboxRelays() as a
// belt-and-braces measure in case the cache hasn't been hydrated
// yet for this contact.
if (welcomeDelivery != null) {
val computed = account.broadcaster.computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)
val recipientInbox =
account.cache
.getOrCreateUser(memberPubKey)
.dmInboxRelays()
.orEmpty()
val relayList = computed + account.outboxRelays.flow.value + recipientInbox
Log.d("MarmotDbg") {
"addMarmotGroupMember: welcome gift wrap relay sources " +
"computeRelayListToBroadcast=${computed.size} myOutbox=${account.outboxRelays.flow.value.size} " +
"recipientInbox=${recipientInbox.size} → union=${relayList.size}"
}
if (relayList.isEmpty()) {
Log.w("MarmotDbg") {
"addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped"
}
} else {
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}" +
"kind:${welcomeDelivery.giftWrapEvent.kind}${relayList.size} relay(s): ${relayList.map { it.url }}"
}
}
account.client.publish(welcomeDelivery.giftWrapEvent, relayList)
} else {
Log.w("MarmotDbg") {
"addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!"
}
}
}
/**
* Relays where this account publishes kind:30443 KeyPackage events.
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
*/
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value)
/**
* Publish or rotate KeyPackage events.
*/
suspend fun publishMarmotKeyPackages() {
val manager =
account.marmotManager ?: run {
Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" }
return
}
if (!account.isWriteable()) {
Log.w("MarmotDbg") { "publishMarmotKeyPackages: account is not writeable — no-op" }
return
}
val relays = keyPackagePublishRelays()
val needsRotation = manager.needsKeyPackageRotation()
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: needsRotation=$needsRotation relays=${relays.size}"
}
if (needsRotation) {
val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList())
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: rotateConsumedKeyPackages produced ${rotatedEvents.size} event(s)"
}
rotatedEvents.forEach { event ->
account.cache.justConsumeMyOwnEvent(event)
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: publishing rotated kind:${event.kind} id=${event.id.take(8)}" +
"${relays.size} relay(s): ${relays.map { it.url }}"
}
account.client.publish(event, relays)
}
}
}
/**
* Generate and publish initial KeyPackage for this account.
*/
suspend fun publishMarmotKeyPackage() {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val relays = keyPackagePublishRelays()
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}"
}
val event = manager.generateKeyPackageEvent(relays.toList())
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}"
}
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relays)
}
/**
* Ensure the local user has at least one active KeyPackage bundle and
* a published KeyPackage event on relays. Called from [init] after
* Marmot state has been restored from disk.
*
* - If [KeyPackageRotationManager] already has an active bundle (from
* the persisted snapshot), we trust the previous session and do
* nothing. The matching kind:30443 should already be on relays from
* when the bundle was first generated.
* - Otherwise we generate a fresh bundle (which is now persisted to
* disk by [KeyPackageRotationManager.generateKeyPackage]) and
* publish the corresponding event.
*
* Best-effort: failures are logged but never propagated. We don't want
* a flaky relay or missing outbox config at startup to crash account
* initialization.
*/
internal suspend fun ensureMarmotKeyPackagePublished() {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
try {
val hasBundle = manager.hasActiveKeyPackages()
Log.d("MarmotDbg") {
"ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${account.signer.pubKey.take(8)}"
}
if (hasBundle) {
return
}
Log.d("MarmotDbg") {
"ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now"
}
publishMarmotKeyPackage()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e)
}
}
/**
* Check if a KeyPackage has been published in this session.
* The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's
* persisted snapshot, so there is no fixed address to query in the cache.
*/
suspend fun hasPublishedKeyPackage(): Boolean {
val manager = account.marmotManager ?: return false
return manager.hasActiveKeyPackages()
}
/**
* Create a new Marmot MLS group.
*/
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
manager.createGroup(nostrGroupId)
// Creator owns the group — mark it as "known" immediately so it
// doesn't appear under "New Requests" before the first message.
account.marmotGroupList.markAsKnown(nostrGroupId)
}
/**
* Leave a Marmot MLS group.
* Publishes the SelfRemove proposal and removes local state.
*
* MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions
* commit dropping themselves from `admin_pubkeys` before issuing a
* SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws
* `IllegalStateException("Admin must self-demote via GroupContextExtensions
* before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and
* SelfRemove proposal both go to the same group relays, demote first so
* peers apply it before they see the SelfRemove.
*/
suspend fun leaveMarmotGroup(
nostrGroupId: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId)
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
// MIP-03 also rejects any GCE commit that leaves the group with zero
// admins. If we're the only one, promote an arbitrary non-self
// member to admin before stepping down.
if (remaining.isEmpty()) {
val heir =
manager
.memberPubkeys(nostrGroupId)
.map { it.pubkey }
.firstOrNull { it != account.signer.pubKey }
if (heir != null) remaining.add(heir)
}
if (remaining.isNotEmpty()) {
val demoted = metadata.copy(adminPubkeys = remaining)
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
account.client.publish(demoteCommit.signedEvent, groupRelays)
}
}
val outbound = manager.leaveGroup(nostrGroupId)
// manager.leaveGroup already wiped MLS state, relay subscriptions and
// the persisted message log. Drop the in-memory chatroom too — that
// releases the strong refs to the decrypted inner notes so LocalCache
// (which holds them weakly) can GC them, and the Notification feed
// (which iterates account.marmotGroupList.rooms) stops surfacing the group.
account.marmotGroupList.removeGroup(nostrGroupId)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* User-initiated "nuclear" reset for the Marmot subsystem.
*
* Wipes every MLS group, every retained epoch secret, every persisted
* KeyPackage bundle, every relay subscription and every in-memory
* chatroom associated with this account. Does NOT broadcast any
* SelfRemove/leave commits to peers — if the user is in this flow at
* all, local state may already be unusable and a graceful leave is
* probably not possible. Peers will see the user as unresponsive until
* their next commit evicts the stale leaf.
*
* A fresh KeyPackage will be republished lazily on the next
* `ensureMarmotKeyPackagePublished` cycle, so the account remains
* reachable for future group invites.
*/
suspend fun resetMarmotState() {
Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${account.signer.pubKey.take(8)}" }
account.marmotManager?.resetAllState()
for (groupId in account.marmotGroupList.allGroupIds()) {
account.marmotGroupList.removeGroup(groupId)
}
}
/**
* Remove a member from a Marmot MLS group.
* Publishes the commit GroupEvent to group relays.
*/
suspend fun removeMarmotGroupMember(
nostrGroupId: HexKey,
targetLeafIndex: Int,
groupRelays: Set<NormalizedRelayUrl>,
) {
Log.d("MarmotDbg") {
"removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " +
"groupRelays=${groupRelays.size}"
}
val manager =
account.marmotManager ?: run {
Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" }
return
}
if (!account.isWriteable()) {
Log.w("MarmotDbg") { "removeMarmotGroupMember: account is not writeable — no-op" }
return
}
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}" +
"to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Update a Marmot MLS group's metadata (name, description, etc.).
* Publishes the commit GroupEvent to group relays.
*/
suspend fun updateMarmotGroupMetadata(
nostrGroupId: HexKey,
metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
// The MLS commit has already been applied locally — surface the new
// metadata in the chatroom now so the UI reflects it without waiting
// for the relay round-trip.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Grant admin privileges to [targetPubKey] in a Marmot MLS group by
* appending them to `admin_pubkeys` via a GroupContextExtensions commit.
*
* No-op if the group has no prior metadata (shouldn't happen outside the
* first bootstrap commit) or the target is already an admin. Callers
* must be an admin themselves — the MLS engine enforces this via the
* MIP-03 authorization gate in `enforceAuthorizedProposalSet`.
*/
suspend fun grantMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (metadata.adminPubkeys.contains(targetPubKey)) return
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
/**
* Revoke admin privileges from [targetPubKey]. Rejects any change that
* would leave the group with zero admins — MIP-03's admin-depletion guard
* in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise
* throw at commit time.
*/
suspend fun revokeMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (!metadata.adminPubkeys.contains(targetPubKey)) return
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
check(remaining.isNotEmpty()) {
"Cannot revoke the last admin from a Marmot group (MIP-03)"
}
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = remaining)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
}
@@ -0,0 +1,554 @@
/*
* 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.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
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.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.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
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
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.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
* join/leave/create/delete/archive groups, threads, invites, pins, member and
* role management, metadata edits, plus the Buzz dialect's DMs, jobs,
* workflows, and typing signals. Event building lives in quartz builders;
* this class wires them to the account's signer and the group's host relay.
*/
class AccountRelayGroupActions(
private val account: Account,
) {
// 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
// list is our own cross-device bookkeeping of what we joined.
/** Send a kind 9021 join request to the group's host relay and remember it. */
suspend fun joinRelayGroup(
channel: RelayGroupChannel,
code: String? = null,
) {
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
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 (!account.isWriteable()) return
val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
account.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 = account.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 = account.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) }) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
results = account.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)
account.broadcaster.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)
account.broadcaster.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 (!account.isWriteable()) return null
val signed = account.signer.sign(JobRequestEvent.build(request, channelId, null))
// Reflect it locally so the board updates immediately (publish only sends to relays).
account.cache.justConsumeMyOwnEvent(signed)
account.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 (!account.isWriteable()) return
val signed = account.signer.sign(JobCancelEvent.build(jobId, "", channelId))
account.cache.justConsumeMyOwnEvent(signed)
account.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 (!account.isWriteable()) return null
val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId))
val signed = account.signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) })
account.cache.justConsumeMyOwnEvent(signed)
account.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 (!account.isWriteable()) return null
val workflowId = RandomInstance.randomChars(16)
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
account.cache.justConsumeMyOwnEvent(signed)
account.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 (!account.isWriteable()) return null
val signed = account.signer.sign(ApprovalGrantEvent.build(runId, note))
account.cache.justConsumeMyOwnEvent(signed)
account.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 (!account.isWriteable()) return null
val signed = account.signer.sign(ApprovalDenyEvent.build(runId, note))
account.cache.justConsumeMyOwnEvent(signed)
account.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 (!account.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 = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(signed)
account.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)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
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)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
account.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
* the new group's id.
*/
suspend fun createRelayGroup(
relay: NormalizedRelayUrl,
groupId: String,
name: String,
about: String? = null,
picture: String? = null,
isPrivate: Boolean = false,
isClosed: Boolean = false,
isHidden: Boolean = false,
isRestricted: Boolean = false,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = null,
channelType: String? = null,
): GroupId {
// 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.
account.broadcaster.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(
groupId,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
)
account.broadcaster.signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
val id = GroupId(groupId, relay)
account.follow(LocalCache.getOrCreateRelayGroupChannel(id))
return id
}
/**
* The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are
* presence-only — public/open/visible/unrestricted are simply the ABSENCE of their
* restrictive counterpart — so only the enabled restrictive flags are added.
*/
private fun relayGroupStatus(
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
): Set<GroupMetadataEvent.GroupStatus> =
buildSet {
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN)
if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
}
/** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */
suspend fun postRelayGroupThread(
channel: RelayGroupChannel,
title: String,
body: String,
) {
val template =
ThreadEvent.build(body, title) {
hTag(channel.groupId.id)
previous(channel.previousEventRefs(account.pubKey))
}
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Mint a kind 9009 invite code for the group (admin/moderator only). */
suspend fun createRelayGroupInvite(
channel: RelayGroupChannel,
code: String,
) {
val template = CreateInviteEvent.build(channel.groupId.id, code)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Replace the group's pinned-message list with a kind 9010 update-pin-list event
* (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and
* republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
*/
suspend fun updateRelayGroupPins(
channel: RelayGroupChannel,
pinnedEventIds: List<HexKey>,
) {
val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Pin [eventId] by appending it to the current list (no-op if already pinned). */
suspend fun pinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds + eventId)
}
/** Unpin [eventId] by removing it from the current list (no-op if not pinned). */
suspend fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (!channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds - eventId)
}
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
suspend fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
) {
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
* event (moderator only). Pass an empty [roles] list for a plain member.
*/
suspend fun putRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
roles: List<String>,
) {
// 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)
account.broadcaster.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,
) {
account.broadcaster.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,
) {
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
}
/**
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
*
* NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy
* links: a 9002 with no `parent` tag re-roots the group, and one that drops any
* existing `child` is rejected by the relay. So unless the caller is explicitly
* re-parenting, we re-carry the group's current [parent] and full [children] list
* from its latest known metadata to keep the tree intact across a plain name/flag
* edit. Pass an explicit value to change them.
*/
suspend fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
about: String?,
picture: String?,
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
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,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
children = children,
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
)
account.broadcaster.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)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
}
@@ -0,0 +1,337 @@
/*
* 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.amethyst.R
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
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.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
/**
* Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47
* NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC
* onchain zaps/sends. Event building lives in the commons ZapActions/
* Bolt12ZapActions; this class wires wallet selection, signing, and relay
* routing to the account.
*/
class AccountZapActions(
private val account: Account,
) {
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
message: String = "",
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
signer = account.signer,
pollOption = pollOption,
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?,
afterTimeInSeconds: Long,
): Boolean = zappedNote?.isZappedBy(account.userProfile(), afterTimeInSeconds, account) == true
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(account.nip47SignerState)
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
account.client.publish(event, setOf(relay))
}
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
): HexKey {
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
account.client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
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 = account.nip47SignerState.defaultWalletUri.value ?: return false
return account.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 ->
account.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 account.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 ->
account.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 (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
account.cache.justConsumeMyOwnEvent(zap)
account.client.publish(zap, account.broadcaster.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 = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
userHex = user.pubkeyHex,
relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
signer = account.signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
account.cache.justConsumeMyOwnEvent(zapRequest)
return zapRequest
}
private fun onchainBackendNotConfigured() =
OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
ONCHAIN_BACKEND_NOT_CONFIGURED,
)
/**
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
* leave it null for a profile zap.
*/
suspend fun sendOnchainZap(
recipientPubKey: HexKey,
amountSats: Long,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
/**
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
*/
suspend fun sendOnchainToAddress(
recipientAddress: String,
amountSats: Long,
feeRateSatPerVByte: Double,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
}
@@ -0,0 +1,492 @@
/*
* 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.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.checkNotInMainThread
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.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Memory-reclaim policy over the [LocalCache] stores: trims the soft caches,
* prunes hidden/old/expired/superseded events, and owns the shared
* [unlinkAndRemove] removal primitive that [LocalCache.deleteNote] also relies on.
*
* Pure policy — it holds no state of its own beyond the cache reference, so every
* function can be exercised against a populated cache in tests. Driven by
* `MemoryTrimmingService`.
*/
class CachePruner(
private val cache: LocalCache,
) {
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" }
cache.notes.cleanUp()
Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${cache.notes.size()}" }
Log.d("LargeCache") { "Addressables cleanup started. Current size: ${cache.addressables.size()}" }
cache.addressables.cleanUp()
Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${cache.addressables.size()}" }
Log.d("LargeCache") { "Users cleanup started. Current size: ${cache.users.size()}" }
cache.users.cleanUp()
Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${cache.users.size()}" }
}
fun cleanObservers() {
cache.notes.forEach { _, it -> it.clearFlow() }
cache.addressables.forEach { _, it -> it.clearFlow() }
}
private fun pruneHiddenMessagesChannel(
channel: Channel,
account: Account,
) {
val toBeRemoved = channel.pruneHiddenMessages(account)
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
fun pruneHiddenMessages(account: Account) {
cache.ephemeralChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.geohashChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.liveChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.publicChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.relayGroupChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
}
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
// `NestsFeedFilter` so a presence still inside any feed's window
// can never be pruned.
private val presencePruneAgeSeconds = 20L * 60L
private fun pruneOldMessagesChannel(channel: Channel) {
val toBeRemoved = channel.pruneOldMessages()
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
// Audio-room presence is keyed separately from `notes` and
// never gets reaped by the top-N rule. Drop entries older
// than 2× the 10-min freshness window so the index doesn't
// grow unbounded with every author who ever heartbeat here.
if (channel is LiveActivitiesChannel) {
channel.pruneStalePresence(TimeUtils.now() - presencePruneAgeSeconds)
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
fun pruneOldMessages() {
checkNotInMainThread()
cache.ephemeralChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.geohashChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.liveChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.publicChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.relayGroupChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.chatroomList.forEach { userHex, room ->
// History floors are pinned per scope on first advance; null means that window never paged
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
// bands strictly BELOW a floor are this window's responsibility — a pruned message newer than
// the floor is the always-on live tail's concern, and rewinding history for it would needlessly
// re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor
// filter when accumulating below.
val giftWrapFloor = room.giftWrapHistory.floor
val accountNip04Floor = room.nip04History.floor
room.rooms.map { key, chatroom ->
val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly()
val childrenToBeRemoved = mutableListOf<Note>()
// Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor.
// Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's
// own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor.
val giftWrapPruned = HashMap<NormalizedRelayUrl, Long>()
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
val roomNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
// chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a
// kind:4 message, so rooms that never paged conversation history pay nothing.
val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null
toBeRemoved.forEach { note ->
when (val ev = note.event) {
is BaseDMGroupEvent ->
if (giftWrapFloor != null) {
val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt
if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) }
}
is PrivateDmEvent -> {
val until = ev.createdAt
if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) }
if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) }
}
}
childrenToBeRemoved.addAll(removeIfWrap(note))
unlinkAndRemove(note)
childrenToBeRemoved.addAll(note.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
// Realign the windows so a relay that already paged past (or `done` below) the dropped band
// re-requests it on the next demand-advance instead of skipping the hole.
if (giftWrapPruned.isNotEmpty()) {
room.giftWrapHistory.rewindTo(giftWrapPruned)
Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" }
}
if (accountNip04Pruned.isNotEmpty()) {
room.nip04History.rewindTo(accountNip04Pruned)
Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" }
}
if (roomNip04Pruned.isNotEmpty()) {
chatroom.nip04History.rewindTo(roomNip04Pruned)
Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" }
}
if (toBeRemoved.size > 1) {
println(
"PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept",
)
}
}
}
}
private fun removeIfWrap(note: Note): List<Note> {
val host = note.rumorHost ?: return emptyList()
val children = mutableListOf<Note>()
cache.getNoteIfExists(host.id)?.let { hostNote ->
(hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId ->
cache.getNoteIfExists(sealId)?.let { sealNote ->
unlinkAndRemove(sealNote)
children.addAll(sealNote.clearChildLinks())
}
}
unlinkAndRemove(hostNote)
children.addAll(hostNote.clearChildLinks())
}
note.rumorHost = null
return children
}
fun prunePastVersionsOfReplaceables() {
val toBeRemoved =
cache.notes.filter { _, note ->
val noteEvent = note.event
if (noteEvent is AddressableEvent) {
noteEvent.createdAt <
(
cache.addressables
.get(noteEvent.address())
?.event
?.createdAt ?: 0
)
} else {
false
}
}
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> cache.addressables.get(tag) }
if (newerVersion != null) {
it.moveAllReferencesTo(newerVersion)
}
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
}
}
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
checkNotInMainThread()
val toBeRemoved =
cache.notes.filter { _, note ->
(
(note.event is TextNoteEvent && !note.isNewThread()) ||
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is ReportEvent ||
note.event is GenericRepostEvent
) &&
note.replyTo?.any { it.flowSet?.isInUse() == true } != true &&
note.flowSet?.isInUse() != true &&
// don't delete if observing.
note.author?.pubkeyHex !in
accounts &&
// don't delete if it is the logged in account
note.event?.isTaggedUsers(accounts) !=
true // don't delete if it's a notification to the logged in user
}
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
/**
* Unlinks [note] from everything in the cache that references it, then drops it
* from the notes map and notifies observers. This is the shared "unlink from
* above" half of removal, used by both the prune callers and [LocalCache.deleteNote].
*
* It detaches the note from:
* - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps);
* because event-level reports and torrent comments both carry the target in
* `replyTo`, [Note.removeNote] cleans those up here too;
* - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote`
* always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders
* resolve so a note can never linger in a channel after leaving the cache);
* - the per-target indexes `replyTo` does NOT reach: user-level reports and
* reported addresses, contact cards, statuses, and poll responses.
*
* It deliberately does NOT touch the note's own children: prune callers collect
* them via [Note.clearChildLinks] and remove the subtree, while [LocalCache.deleteNote]
* keeps them and severs only their back-reference. Every per-target removal is
* idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an
* event-level report reachable both ways) is harmless. Addressable notes are
* dropped from the addressables map by the caller; this only removes from notes.
*/
fun unlinkAndRemove(note: Note) {
note.replyTo?.forEach { masterNote ->
masterNote.removeNote(note)
}
note.inGatherers?.forEach { it.removeNote(note) }
cache.getAnyChannel(note)?.removeNote(note)
val noteEvent = note.event
// Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so
// detach this note from every quoted note's boosts here.
noteEvent?.taggedQuoteIds()?.forEach { quotedId ->
cache.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 { cache.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 { cache.getNoteIfExists(it)?.removeTimestamp(note) }
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
}
noteEvent.reportedPost().forEach {
cache.getNoteIfExists(it.eventId)?.removeReport(note)
}
noteEvent.reportedAddresses().forEach {
cache.getAddressableNoteIfExists(it.address)?.removeReport(note)
}
}
if (note is AddressableNote && noteEvent is ContactCardEvent) {
cache.getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
}
if (note is AddressableNote && noteEvent is StatusEvent) {
note.author?.statusStateOrNull()?.removeStatus(note)
}
if (noteEvent is PollResponseEvent) {
noteEvent.poll()?.eventId?.let {
cache.getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note)
}
}
note.clearFlow()
cache.notes.remove(note.idHex)
cache.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) }
}
fun pruneExpiredEvents() {
checkNotInMainThread()
val now = TimeUtils.now()
val versionsToBeRemoved = cache.notes.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val addressesToBeRemoved = cache.addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val childrenToBeRemoved = mutableListOf<Note>()
versionsToBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
addressesToBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
}
}
fun pruneHiddenEvents(account: Account) {
checkNotInMainThread()
val childrenToBeRemoved = mutableListOf<Note>()
val toBeRemoved =
account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex ->
(cache.notes.filter { _, it -> it.event?.pubKey == userHex } + cache.addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet()
}
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
}
@@ -0,0 +1,266 @@
/*
* 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.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.CancellationException
/**
* Prefix/content search over the [LocalCache] stores: users, notes, and the
* public-chat / ephemeral / live-activity channel maps. Pure read-side policy —
* no state beyond the cache reference — so ranking and filtering rules can be
* tested against a populated cache.
*/
class CacheSearch(
private val cache: LocalCache,
) {
fun findUsersStartingWith(
username: String,
forAccount: Account?,
): List<User> {
if (username.isBlank()) return emptyList()
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
if (key != null) {
val user = cache.getUserIfExists(key)
if (user != null) {
return listOfNotNull(user)
}
}
val dualCase =
listOf(
DualCase(username.lowercase(), username.uppercase()),
)
val finds =
cache.users.filter { _, user: User ->
val metadata = user.metadataOrNull()
if (metadata == null) {
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
} else {
(
metadata.anyNameOrAddressContains(dualCase) ||
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
) &&
(forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase)))
}
}
val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true }
val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true }
val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true }
val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() }
return finds.sortedWith(
compareBy(
{ findsFollowing[it] == false },
{ anyNameStartsWith[it] == false },
{ anyAddressStartsWith[it] == false },
{ displayNames[it] },
{ it.pubkeyHex },
),
)
}
/**
* Will return true if supplied note is one of events to be excluded from
* search results.
*/
private fun excludeNoteEventFromSearchResults(note: Note): Boolean =
(
note.event is GenericRepostEvent ||
note.event is RepostEvent ||
note.event is CommunityPostApprovalEvent ||
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is FileHeaderEvent ||
note.event is MetadataEvent ||
note.event is ContactListEvent ||
note.event is AppSpecificDataEvent
)
/**
* Tag names whose values should not match text searches: the `client` tag
* names the app that published the event (searching for "Amethyst" would
* otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt`
* values are ids or descriptions of other events, not content of this one.
*/
private val excludedTagNamesFromSearch =
setOf(
ClientTag.TAG_NAME,
PTag.TAG_NAME,
ETag.TAG_NAME,
ATag.TAG_NAME,
AltTag.TAG_NAME,
)
fun findNotesStartingWith(
text: String,
hiddenUsers: HiddenUsersState,
): List<Note> {
checkNotInMainThread()
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null) {
val note = cache.getNoteIfExists(key)
val noteEvent = note?.event
val newNote =
if (noteEvent is AddressableEvent) {
val addressableNote = cache.getAddressableNoteIfExists(noteEvent.address())
if (addressableNote?.event?.id == note.idHex) {
addressableNote
} else {
note
}
} else {
note
}
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
return listOfNotNull(newNote)
}
}
return cache.notes.filter { _, note ->
if (note.event is AddressableEvent) {
return@filter false
}
if (excludeNoteEventFromSearchResults(note)) {
return@filter false
}
if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
note.idHex.startsWith(text, true)
) {
return@filter !note.isHiddenFor(hiddenUsers.flow.value)
}
if (note.event?.isContentEncoded() == false) {
return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) {
note.event?.content?.contains(text, true) ?: false
} else {
false
}
}
return@filter false
} +
cache.addressables.filter { _, addressable ->
if (excludeNoteEventFromSearchResults(addressable)) {
return@filter false
}
if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
addressable.idHex.startsWith(text, true)
) {
return@filter !addressable.isHiddenFor(hiddenUsers.flow.value)
}
if (addressable.event?.isContentEncoded() == false) {
return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) {
addressable.event?.content?.contains(text, true) ?: false
} else {
false
}
}
return@filter false
}
}
fun findPublicChatChannelsStartingWith(text: String): List<PublicChatChannel> {
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null) {
cache.getPublicChatChannelIfExists(key)?.let {
return listOf(it)
}
}
return cache.publicChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
fun findEphemeralChatChannelsStartingWith(text: String): List<EphemeralChatChannel> {
if (text.isBlank()) return emptyList()
return cache.ephemeralChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
fun findLiveActivityChannelsStartingWith(text: String): List<LiveActivitiesChannel> {
if (text.isBlank()) return emptyList()
try {
val parsed = Nip19Parser.uriToRoute(text)?.entity
if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) {
return listOf(cache.getOrCreateLiveChannel(parsed.address()))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
return cache.liveChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
}
@@ -0,0 +1,37 @@
/*
* 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.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* The minimal get-or-create surface of the event cache, used by callers (like
* `NewMessageTagger`) that resolve user/note references while composing without
* needing the full [LocalCache] API.
*/
interface Dao {
fun getOrCreateUser(hex: HexKey): User
fun getOrCreateNote(hex: HexKey): Note
fun getOrCreateAddressableNote(address: Address): AddressableNote?
}
@@ -0,0 +1,431 @@
/*
* 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.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
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.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
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.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
/**
* The sign-and-publish choke point for an [Account]: computes the relay set an
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
* relays, broadcast lists, DM inboxes) and owns every publish path - automatic,
* outbox-only, everywhere, private-relay-list, anonymous, and rebroadcast.
*
* Feature orchestration on [Account] (and the Account*Actions classes) should
* funnel every publish through this class instead of calling the relay client
* directly.
*/
class EventBroadcaster(
private val account: Account,
) {
private fun computeRelayListForLinkedUser(user: User): Set<NormalizedRelayUrl> =
if (user == account.userProfile()) {
account.notificationRelays.flow.value
} else {
user.inboxRelays()?.ifEmpty { null }?.toSet()
?: (
account.cache.relayHints
.hintsForKey(user.pubkeyHex)
.toSet() + user.allUsedRelays()
)
}
private fun computeRelayListForLinkedUser(pubkey: HexKey): Set<NormalizedRelayUrl> =
if (pubkey == account.userProfile().pubkeyHex) {
account.notificationRelays.flow.value
} else {
account.cache
.getUserIfExists(pubkey)
?.inboxRelays()
?.ifEmpty { null }
?.toSet()
?: account.cache.relayHints
.hintsForKey(pubkey)
.toSet()
}
private fun computeRelaysForChannels(event: Event): Set<NormalizedRelayUrl> = account.cache.getAnyChannel(event)?.relays() ?: emptySet()
// Personal events the user stores just for themselves — drafts, app settings, bookmark
// lists — and channel/community events that already declare their own home relays
// should not be replicated to the user's broadcasting relays. Channel/community events
// that don't define any home relays fall through to broadcast, since there's nowhere
// else for them to land.
private fun wantsBroadcastRelays(event: Event): Boolean {
if (event is DraftWrapEvent ||
event is AppSpecificDataEvent ||
event is BookmarkListEvent ||
event is OldBookmarkListEvent ||
event is LabeledBookmarkListEvent
) {
return false
}
if (event is PollEvent && event.relays().isNotEmpty()) return false
if (event is MeetingSpaceEvent && event.allRelayUrls().isNotEmpty()) return false
if (event is MeetingRoomEvent && event.allRelayUrls().isNotEmpty()) return false
if (event is LiveActivitiesEvent && event.allRelayUrls().isNotEmpty()) return false
val channelRelays = account.cache.getAnyChannel(event)?.relays()
if (channelRelays != null && channelRelays.isNotEmpty()) return false
return true
}
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> = computeRelayListToBroadcast(event, mutableSetOf())
private fun computeRelayListToBroadcast(
event: Event,
visited: MutableSet<HexKey>,
): Set<NormalizedRelayUrl> {
// a-tagged events can form cycles; without this the two recursive descents stack-overflow.
if (!visited.add(event.id)) return emptySet()
if (event is GiftWrapEvent) {
val receiver = event.recipientPubKey()
return if (receiver != null) {
val relayList =
account.cache
.getOrCreateUser(receiver)
.dmInboxRelayList()
?.relays()
?.ifEmpty { null }
relayList?.toSet() ?: computeRelayListForLinkedUser(receiver)
} else {
emptySet()
}
}
// Seals, inner DM messages, and unsigned rumors never get broadcast
// relays: they only travel inside gift wraps.
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
return emptySet()
}
val includeBroadcast = wantsBroadcastRelays(event)
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
// everywhere
return account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + broadcastRelays
}
val relayList = mutableSetOf<NormalizedRelayUrl>()
relayList.addAll(broadcastRelays)
val author = account.cache.getUserIfExists(event.pubKey)
if (author != null) {
if (author == account.userProfile()) {
if (includeBroadcast) {
relayList.addAll(account.outboxRelays.flow.value)
} else {
// account.outboxRelays mixes in the broadcast list; for personal/channel events
// we want the user's NIP-65 / private / local outbox without it.
relayList.addAll(account.nip65RelayList.outboxFlow.value)
relayList.addAll(account.privateStorageRelayList.flow.value)
relayList.addAll(account.localRelayList.flow.value)
}
} else {
val relays =
author.outboxRelays()?.ifEmpty { null }
?: author.allUsedRelaysOrNull()
?: account.cache.relayHints.hintsForKey(author.pubkeyHex)
relayList.addAll(relays)
}
} else {
relayList.addAll(account.cache.relayHints.hintsForKey(event.pubKey))
}
if (event is PubKeyHintProvider) {
event.pubKeyHints().forEach {
relayList.add(it.relay)
}
event.linkedPubKeys().forEach { pubkey ->
relayList.addAll(computeRelayListForLinkedUser(pubkey))
}
}
if (event is EventHintProvider) {
event.eventHints().forEach {
relayList.add(it.relay)
}
event.linkedEventIds().forEach { eventId ->
account.cache.getNoteIfExists(eventId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
if (linkedNoteAuthor != null) {
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
} else {
relayList.addAll(linkedNote.relays.toSet())
}
linkedNote.event?.let { linkedEvent ->
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
}
}
}
}
if (event is AddressHintProvider) {
event.addressHints().forEach {
relayList.add(it.relay)
}
event.linkedAddressIds().forEach { addressId ->
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
if (linkedNoteAuthor != null) {
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
} else {
relayList.addAll(linkedNote.relays.toSet())
}
linkedNote.event?.let { linkedEvent ->
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
}
}
}
}
if (event is PollEvent) {
relayList.addAll(event.relays())
}
if (event is MeetingSpaceEvent) {
relayList.addAll(event.allRelayUrls())
}
if (event is MeetingRoomEvent) {
relayList.addAll(event.allRelayUrls())
}
if (event is LiveActivitiesEvent) {
relayList.addAll(event.allRelayUrls())
}
relayList.addAll(computeRelaysForChannels(event))
return relayList
}
fun computeRelayListToBroadcast(note: Note): Set<NormalizedRelayUrl> {
val noteEvent = note.event
return if (noteEvent != null) {
computeRelayListToBroadcast(noteEvent)
} else {
note.relays.toSet()
}
}
suspend fun broadcast(note: Note) {
note.event?.let { noteEvent ->
val host = note.rumorHost
if (host != null) {
// Rumors are rebroadcast as their delivering envelope: the
// cached copy is content-stripped, so download it and send it.
// A just-sent note has no relays until its self-wrap echoes
// back — fall back to our own DM inbox relays. Bare seals
// (kind 13) carry no p tag, so that filter is wrap-only.
val relays =
note.relays.ifEmpty {
account.dmRelays.flow.value
.toList()
}
val filter =
if (host.kind == SealedRumorEvent.KIND) {
Filter(
kinds = listOf(host.kind),
ids = listOf(host.id),
)
} else {
Filter(
kinds = listOf(host.kind),
tags = mapOf("p" to listOf(account.pubKey)),
ids = listOf(host.id),
)
}
account.client
.fetchFirst(
filters = relays.associateWith { _ -> listOf(filter) },
)?.let { downloadedEvent ->
val toRelays = computeRelayListToBroadcast(downloadedEvent)
account.client.publish(downloadedEvent, toRelays)
}
} else if (noteEvent.sig.isEmpty()) {
// Rumor with no known wrap: publishing it would disclose the
// private content to relays even though they reject the
// missing signature.
return
} else {
account.client.publish(noteEvent, computeRelayListToBroadcast(note))
}
}
}
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
fun sendAutomatic(event: Event?) {
if (event == null) return
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, computeRelayListToBroadcast(event))
}
fun sendMyPublicAndPrivateOutbox(event: Event?) {
if (event == null) return
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, account.outboxRelays.flow.value)
}
fun sendMyPublicAndPrivateOutbox(events: List<Event>) {
events.forEach {
account.client.publish(it, account.outboxRelays.flow.value)
account.cache.justConsumeMyOwnEvent(it)
}
}
fun sendLiterallyEverywhere(event: Event) {
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
account.cache.justConsumeMyOwnEvent(event)
}
suspend fun <T : Event> signAndSendPrivately(
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
) {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relayList)
}
/**
* Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral
* identity that is deliberately NOT this account's key) and publish to exactly
* [relayList]. Used by geohash location chat, where authorship inside a cell
* must not be linkable to the user's npub.
*/
suspend fun <T : Event> signWithAndSendPrivately(
template: EventTemplate<T>,
signer: NostrSigner,
relayList: Set<NormalizedRelayUrl>,
): T {
val event = signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
if (relayList.isNotEmpty()) account.client.publish(event, relayList)
return event
}
suspend fun <T : Event> signAndSendPrivatelyOrBroadcast(
template: EventTemplate<T>,
relayList: (T) -> List<NormalizedRelayUrl>?,
): T {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val relays = relayList(event)
val targets =
if (!relays.isNullOrEmpty()) {
relays.toSet()
} else {
computeRelayListToBroadcast(event)
}
account.chatDeliveryTracker.trackPublic(event.id, targets)
account.client.publish(event, targets)
return event
}
suspend fun <T : Event> signAndComputeBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
): T {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
account.cache.getOrCreateAddressableNote(event.address())
} else {
account.cache.getOrCreateNote(event.id)
}
val relayList = computeRelayListToBroadcast(note)
account.client.publish(event, relayList)
broadcast.forEach { account.client.publish(it, relayList) }
return event
}
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
): T {
val event = anonymousSigner.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
account.cache.getOrCreateAddressableNote(event.address())
} else {
account.cache.getOrCreateNote(event.id)
}
val relayList = computeRelayListToBroadcast(note)
account.client.publish(event, relayList)
broadcast.forEach { account.client.publish(it, relayList) }
return event
}
fun republishEventsTo(
events: List<Event>,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty() || events.isEmpty()) return
events.forEach { account.client.publish(it, relays) }
}
}
File diff suppressed because it is too large Load Diff
@@ -279,7 +279,7 @@ class AccountNappletGateways(
}
val result = CompletableDeferred<String?>()
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
when (response) {
is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage)
is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed."))
@@ -159,7 +159,7 @@ class V4VPaymentHandler(
tlvRecords = tlvRecords,
)
account.sendNwcRequest(request) { response: Response? ->
account.zaps.sendNwcRequest(request) { response: Response? ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -195,7 +195,7 @@ class V4VPaymentHandler(
try {
val nostrRequest =
if (asZap && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = null,
message = message,
@@ -250,7 +250,7 @@ class V4VPaymentHandler(
is PaymentSource.Nwc -> {
var done = 0
payables.forEach { payable ->
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -163,7 +163,7 @@ class ZapPaymentHandler(
val canBolt12 =
account.settings.nwcWallets.value
.isNotEmpty() &&
account.defaultWalletSupportsBolt12Pay()
account.zaps.defaultWalletSupportsBolt12Pay()
val bolt12Recipients =
unverifiedZapsToSend.mapNotNull {
@@ -330,7 +330,7 @@ class ZapPaymentHandler(
val zapRequest =
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = pollOption,
message = message,
@@ -414,7 +414,7 @@ class ZapPaymentHandler(
return mapNotNullAsync(
items = payables,
runRequestFor = { payable: Payable ->
account.sendZapPaymentRequestFor(
account.zaps.sendZapPaymentRequestFor(
bolt11 = payable.invoice,
zappedNote = note,
onResponse = { response ->
@@ -462,7 +462,7 @@ class ZapPaymentHandler(
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
account.sendBolt12Zap(
account.zaps.sendBolt12Zap(
zappedEvent = note.event,
recipientPubKey = recipient.user.pubkeyHex,
offer = recipient.offer,
@@ -54,21 +54,21 @@ class MemoryTrimmingService(
) {
// Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are
// not currently held by the UI, so it is safe and inexpensive at any pressure level.
cache.cleanMemory()
cache.cleanObservers()
cache.pruneExpiredEvents()
cache.prunePastVersionsOfReplaceables()
cache.pruner.cleanMemory()
cache.pruner.cleanObservers()
cache.pruner.pruneExpiredEvents()
cache.pruner.prunePastVersionsOfReplaceables()
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
// messages, and unobserved reactions.
account.forEach {
cache.pruneHiddenEvents(it)
cache.pruneHiddenMessages(it)
cache.pruner.pruneHiddenEvents(it)
cache.pruner.pruneHiddenMessages(it)
}
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneRepliesAndReactions(accounts)
cache.pruner.pruneOldMessages()
cache.pruner.pruneRepliesAndReactions(accounts)
}
}
@@ -189,7 +189,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
persistOwn = false,
)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
}
private suspend fun sendPublicReply(
@@ -166,7 +166,7 @@ object BlossomPaymentHandler {
val preimageResult = CompletableDeferred<String?>()
try {
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
}
@@ -21,10 +21,9 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Dao
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -258,11 +257,3 @@ class NewMessageTagger(
return null
}
}
interface Dao {
fun getOrCreateUser(hex: HexKey): User
fun getOrCreateNote(hex: HexKey): Note
fun getOrCreateAddressableNote(address: Address): AddressableNote?
}
@@ -76,7 +76,7 @@ fun ConcordInviteCard(
// Peek the bundle once per link to reveal the community name (null until it resolves).
val invite by produceState<CommunityInvite?>(initialValue = null, linkText) {
value = accountViewModel.account.peekConcordInvite(linkText)
value = accountViewModel.account.concord.peekConcordInvite(linkText)
}
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
@@ -275,8 +275,8 @@ fun CardBody(
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
// Concord moderation: only present when this account may actually act.
val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null }
val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) }
val canConcordBan = remember(note) { accountViewModel.account.concord.concordBanTarget(note) != null }
val concordAdmin = remember(note) { accountViewModel.account.concord.concordAdminTarget(note) }
val showConcordBanDialog = remember { mutableStateOf(false) }
if (showConcordBanDialog.value) {
@@ -103,7 +103,7 @@ class PollNoteViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = false
wasZappedByLoggedInAccount = account.calculateIfNoteWasZappedByAccount(pollNote, 0)
wasZappedByLoggedInAccount = account.zaps.calculateIfNoteWasZappedByAccount(pollNote, 0)
canZap.value = checkIfCanZap()
tallies.forEach {
@@ -190,7 +190,7 @@ class UserSuggestionState(
if (prefix != null) {
logTime("UserSuggestionState Search $prefix version $version") {
rankPriorityFirst(
account.cache.findUsersStartingWith(prefix, account),
account.cache.search.findUsersStartingWith(prefix, account),
priorityPubkeys(),
)
}
@@ -371,7 +371,7 @@ fun noteActionSections(
// message's author (both return null unless it's a Concord message this
// account may act on). Promote/demote is instant; a ban re-keys the
// community, so it defers to the surface's confirmation dialog.
val concordAdmin = accountViewModel.account.concordAdminTarget(note)
val concordAdmin = accountViewModel.account.concord.concordAdminTarget(note)
if (concordAdmin != null) {
val isAdmin = concordAdmin.third
add(
@@ -384,7 +384,7 @@ fun noteActionSections(
},
)
}
if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) {
if (handlers.onConcordBan != null && accountViewModel.account.concord.concordBanTarget(note) != null) {
add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan))
}
}
@@ -150,7 +150,7 @@ fun GoalProgressBar(
LaunchedEffect(key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.account.calculateZappedAmount(note)
val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note)
var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat()
if (percentage > 1) percentage = 1f
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Dao
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UiSettingsFlow
@@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismis
import com.vitorpamplona.amethyst.service.pow.powKindLabelRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
@@ -548,7 +548,7 @@ class AccountViewModel(
// public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an
// existing Concord reaction is a follow-up; for now this only adds one.)
if (note.inGatherers?.any { it is ConcordChannel } == true) {
launchSigner { account.reactToConcordMessage(note, reaction) }
launchSigner { account.concord.reactToConcordMessage(note, reaction) }
return
}
@@ -606,15 +606,15 @@ class AccountViewModel(
/** Ban the author of a Concord channel message (no-op unless this account may ban them). */
fun banConcordMember(note: Note) {
val (communityId, member) = account.concordBanTarget(note) ?: return
launchSigner { account.banConcordMember(communityId, member) }
val (communityId, member) = account.concord.concordBanTarget(note) ?: return
launchSigner { account.concord.banConcordMember(communityId, member) }
}
/** Toggle the Admin role on the author of a Concord channel message (owner only). */
fun toggleConcordAdmin(note: Note) {
val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return
val (communityId, member, isAdmin) = account.concord.concordAdminTarget(note) ?: return
launchSigner {
if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member)
if (isAdmin) account.concord.removeConcordAdmin(communityId, member) else account.concord.makeConcordAdmin(communityId, member)
}
}
@@ -624,7 +624,7 @@ class AccountViewModel(
member: HexKey,
makeAdmin: Boolean,
) = launchSigner {
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
if (makeAdmin) account.concord.makeConcordAdmin(communityId, member) else account.concord.removeConcordAdmin(communityId, member)
}
/**
@@ -640,7 +640,7 @@ class AccountViewModel(
member: HexKey,
roleIds: List<String>,
) = launchSigner {
if (!account.grantConcordRole(communityId, member, roleIds)) {
if (!account.concord.grantConcordRole(communityId, member, roleIds)) {
toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed)
}
}
@@ -651,7 +651,7 @@ class AccountViewModel(
member: HexKey,
ban: Boolean,
) = launchSigner {
if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member)
if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member)
}
/**
@@ -663,7 +663,7 @@ class AccountViewModel(
communityId: String,
member: HexKey,
) = launchSigner {
account.refoundConcordCommunity(communityId, setOf(member))
account.concord.refoundConcordCommunity(communityId, setOf(member))
}
/**
@@ -683,7 +683,7 @@ class AccountViewModel(
else -> emptyList()
}
}.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
account.importConcordCommunities(pinnedRelays)
account.concord.importConcordCommunities(pinnedRelays)
}
/** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */
@@ -691,12 +691,12 @@ class AccountViewModel(
communityId: String,
channelIdHex: String,
) = viewModelScope.launch(Dispatchers.IO) {
account.sendConcordTyping(communityId, channelIdHex)
account.concord.sendConcordTyping(communityId, channelIdHex)
}
fun sendBuzzTyping(channel: RelayGroupChannel) =
viewModelScope.launch(Dispatchers.IO) {
account.sendBuzzTyping(channel)
account.relayGroups.sendBuzzTyping(channel)
}
@Immutable
@@ -843,7 +843,7 @@ class AccountViewModel(
afterTimeInSeconds: Long,
): Boolean =
withContext(Dispatchers.IO) {
account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
account.zaps.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
}
suspend fun calculateZapAmount(zappedNote: Note): String {
@@ -854,7 +854,7 @@ class AccountViewModel(
val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex)
return if (zappedNote.zapPayments.isNotEmpty()) {
withContext(Dispatchers.IO) {
val nwc = account.calculateZappedAmount(zappedNote)
val nwc = account.zaps.calculateZappedAmount(zappedNote)
showAmount(nwc + java.math.BigDecimal(ownPendingOnchain))
}
} else {
@@ -866,7 +866,7 @@ class AccountViewModel(
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
return if (zappedNote.zapPayments.isNotEmpty()) {
withContext(Dispatchers.IO) {
val newZapAmount = account.calculateZappedAmount(zappedNote)
val newZapAmount = account.zaps.calculateZappedAmount(zappedNote)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
@@ -1202,7 +1202,7 @@ class AccountViewModel(
.isNotEmpty()
/** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay()
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay()
/**
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
@@ -1214,7 +1214,7 @@ class AccountViewModel(
offer: String,
amountMillisats: Long,
) = launchSigner {
account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
account.zaps.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
when (response) {
is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent)
is IErrorResponseLike ->
@@ -1668,12 +1668,12 @@ class AccountViewModel(
fun joinRelayGroup(
channel: RelayGroupChannel,
code: String? = null,
) = launchSigner { account.joinRelayGroup(channel, code) }
) = launchSigner { account.relayGroups.joinRelayGroup(channel, code) }
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) }
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) }
/**
* Archive/unarchive a Buzz channel (kind-9002 `archived` tag) hides it from the sidebar without
@@ -1682,7 +1682,7 @@ class AccountViewModel(
fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) = launchSigner { account.archiveRelayGroup(channel, archived) }
) = launchSigner { account.relayGroups.archiveRelayGroup(channel, archived) }
/**
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
@@ -1721,7 +1721,7 @@ class AccountViewModel(
* Hide a Buzz DM from Messages (kind-41012). DM-specific a DM has no kind-10009 entry; the relay
* republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it.
*/
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) }
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.relayGroups.hideBuzzDm(channel) }
/**
* Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with
@@ -1731,7 +1731,7 @@ class AccountViewModel(
fun unhideBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
) = launchSigner { account.openBuzzDm(relay, participants) }
) = launchSigner { account.relayGroups.openBuzzDm(relay, participants) }
/**
* Keep the channel off Messages without touching membership. Local and reversible I stay in the
@@ -1745,7 +1745,7 @@ class AccountViewModel(
/** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */
fun leaveChannelInvite(channel: RelayGroupChannel) =
launchSigner {
account.leaveRelayGroup(channel)
account.relayGroups.leaveRelayGroup(channel)
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
}
@@ -1756,7 +1756,7 @@ class AccountViewModel(
* what makes leaving a community whose own relays are dead work at all the list lives in *our*
* outbox, not in the community's relays.
*/
fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) }
fun leaveConcordCommunity(communityId: String) = launchSigner { account.concord.leaveConcordCommunity(communityId) }
fun createRelayGroup(
relay: NormalizedRelayUrl,
@@ -1771,7 +1771,7 @@ class AccountViewModel(
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.createRelayGroup(
account.relayGroups.createRelayGroup(
relay,
groupId,
name,
@@ -1789,47 +1789,47 @@ class AccountViewModel(
fun createRelayGroupInvite(
channel: RelayGroupChannel,
code: String,
) = launchSigner { account.createRelayGroupInvite(channel, code) }
) = launchSigner { account.relayGroups.createRelayGroupInvite(channel, code) }
fun postRelayGroupThread(
channel: RelayGroupChannel,
title: String,
body: String,
) = launchSigner { account.postRelayGroupThread(channel, title, body) }
) = launchSigner { account.relayGroups.postRelayGroupThread(channel, title, body) }
fun pinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) }
) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) }
fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) }
) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) }
fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
) = launchSigner { account.removeRelayGroupUser(channel, pubkey) }
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
fun putRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
roles: List<String>,
) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) }
) = launchSigner { account.relayGroups.putRelayGroupUser(channel, pubkey, roles) }
/** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */
fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) = launchSigner { account.addCommunityMember(relay, pubkey, role) }
) = launchSigner { account.relayGroups.addCommunityMember(relay, pubkey, role) }
/** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */
fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) = launchSigner { account.removeCommunityMember(relay, pubkey) }
) = launchSigner { account.relayGroups.removeCommunityMember(relay, pubkey) }
fun editRelayGroupMetadata(
channel: RelayGroupChannel,
@@ -1843,7 +1843,7 @@ class AccountViewModel(
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.editRelayGroupMetadata(
account.relayGroups.editRelayGroupMetadata(
channel,
name,
about,
@@ -2330,8 +2330,8 @@ class AccountViewModel(
mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(),
)
?: return
val relays = account.marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
}
suspend fun sendMarmotGroupMediaMessage(
@@ -2356,21 +2356,21 @@ class AccountViewModel(
account.signer.pubKey,
template,
)
val relays = account.marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
}
fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId)
suspend fun createMarmotGroup(nostrGroupId: String) {
account.createMarmotGroup(nostrGroupId)
account.marmot.createMarmotGroup(nostrGroupId)
}
suspend fun publishMarmotKeyPackage() {
account.publishMarmotKeyPackage()
account.marmot.publishMarmotKeyPackage()
}
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage()
/**
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
@@ -2394,12 +2394,12 @@ class AccountViewModel(
}
suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.leaveMarmotGroup(nostrGroupId, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.leaveMarmotGroup(nostrGroupId, relays)
}
suspend fun resetMarmotState() {
account.resetMarmotState()
account.marmot.resetMarmotState()
}
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
@@ -2407,30 +2407,30 @@ class AccountViewModel(
suspend fun addMarmotGroupMember(
nostrGroupId: String,
memberPubKey: String,
): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
): String = account.marmot.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
suspend fun removeMarmotGroupMember(
nostrGroupId: String,
targetLeafIndex: Int,
) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
}
suspend fun grantMarmotGroupAdmin(
nostrGroupId: String,
targetPubKey: String,
) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
}
suspend fun revokeMarmotGroupAdmin(
nostrGroupId: String,
targetPubKey: String,
) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
}
/**
@@ -2486,8 +2486,8 @@ class AccountViewModel(
imageUploadKey = icon.upload.imageUploadKey,
)
}
val relays = account.marmotGroupRelays(nostrGroupId)
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
}
override fun onCleared() {
@@ -2745,7 +2745,7 @@ class AccountViewModel(
onSent: () -> Unit = {},
onResponse: (Response?) -> Unit,
) = launchSigner {
account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
onSent()
}
@@ -2801,7 +2801,7 @@ class AccountViewModel(
if (effectiveZapType != LnZapEvent.ZapType.NONZAP) {
// NIP-57 Appendix F: include amount + lnurl so the receipt can be validated.
val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32)
account.createZapRequestFor(
account.zaps.createZapRequestFor(
user = user,
message = message,
zapType = effectiveZapType,
@@ -308,7 +308,7 @@ class GiftWrapEventHandler(
// already folded the state they carried, so drop the durable wrap note now
// to keep LocalCache from growing without bound.
if (event is EphemeralGiftWrapEvent) {
cache.unlinkAndRemove(listOf(eventNote))
cache.pruner.unlinkAndRemove(listOf(eventNote))
}
return
}
@@ -415,7 +415,7 @@ private suspend fun processMarmotWelcomeFlow(
// Rotate KeyPackages if needed
if (result.needsKeyPackageRotation) {
account.publishMarmotKeyPackages()
account.marmot.publishMarmotKeyPackages()
}
// Fire the "You've been added to <group>" notification. Welcomes
@@ -436,7 +436,10 @@ private fun AgentKeyPicker(
delay(150)
suggestions =
withContext(Dispatchers.IO) {
LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8)
LocalCache.search
.findUsersStartingWith(query.trim(), accountViewModel.account)
.map { it.pubkeyHex }
.take(8)
}
}
@@ -169,33 +169,33 @@ class AgentWorkBoardViewModel : ViewModel() {
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, channelId ->
if (requireApproval) {
account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
account.relayGroups.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
} else {
account.fileBuzzJob(relay, channelId, text) != null
account.relayGroups.fileBuzzJob(relay, channelId, text) != null
}
}
fun approve(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null }
) = act(onResult) { account, relay, _ -> account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null }
fun deny(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null }
) = act(onResult) { account, relay, _ -> account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null }
fun upvote(
jobId: HexKey,
jobAuthor: HexKey?,
) = act({}) { account, relay, channelId ->
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
true
}
fun cancel(jobId: HexKey) =
act({}) { account, relay, channelId ->
account.cancelBuzzJob(relay, channelId, jobId)
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
true
}
@@ -211,7 +211,7 @@ private fun DmRowCard(
addMemberOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.addBuzzDmMember(channel, hex)
accountViewModel.account.relayGroups.addBuzzDmMember(channel, hex)
}
},
)
@@ -254,7 +254,7 @@ class BuzzDmListViewModel : ViewModel() {
fun removeFromMessages(row: DmRow) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
account.relayGroups.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
}
}
@@ -268,7 +268,7 @@ class BuzzDmListViewModel : ViewModel() {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val me = account.userProfile().pubkeyHex
account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
account.relayGroups.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
// The relay's new 30622 normally arrives on the live subscription; refresh anyway so the
// row returns even if this screen's socket missed the snapshot.
refresh()
@@ -118,7 +118,7 @@ class BuzzNewDmViewModel : ViewModel() {
val me = account.userProfile().pubkeyHex
val already = _participants.value.toSet()
val ranked =
LocalCache
LocalCache.search
.findUsersStartingWith(text.trim(), account)
.asSequence()
.map { it.pubkeyHex }
@@ -194,7 +194,7 @@ class BuzzNewDmViewModel : ViewModel() {
_status.value = Status.Sending
viewModelScope.launch(Dispatchers.IO) {
try {
val channelId = account.openBuzzDm(relay, others)
val channelId = account.relayGroups.openBuzzDm(relay, others)
val groupId = channelId?.let { GroupId(it, relay) }
withContext(Dispatchers.Main) { onOpened(groupId) }
} catch (e: CancellationException) {
@@ -112,19 +112,19 @@ class JobBoardViewModel : ViewModel() {
fun file(request: String) =
act { account, relay, channelId ->
account.fileBuzzJob(relay, channelId, request)
account.relayGroups.fileBuzzJob(relay, channelId, request)
}
fun upvote(
jobId: String,
jobAuthor: String?,
) = act { account, relay, channelId ->
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
}
fun cancel(jobId: String) =
act { account, relay, channelId ->
account.cancelBuzzJob(relay, channelId, jobId)
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
}
private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) {
@@ -199,21 +199,21 @@ class WorkflowRunBoardViewModel : ViewModel() {
task: String,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, channelId ->
account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
account.relayGroups.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
}
fun approve(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ ->
account.approveBuzzWorkflowRun(relay, runId) != null
account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null
}
fun deny(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ ->
account.denyBuzzWorkflowRun(relay, runId) != null
account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null
}
/**
@@ -234,7 +234,7 @@ class WorkflowRunBoardViewModel : ViewModel() {
return
}
viewModelScope.launch(Dispatchers.IO) {
val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml)
val newId = account.relayGroups.publishBuzzWorkflowDef(relay, channelId, name, yaml)
withContext(Dispatchers.Main) { onResult(newId) }
}
}
@@ -190,9 +190,9 @@ fun ConcordChannelListScreen(
channelEditor = null
scope.launch {
if (editor.channelIdHex == null) {
account.createConcordChannel(communityId, newName)
account.concord.createConcordChannel(communityId, newName)
} else {
account.renameConcordChannel(communityId, editor.channelIdHex, newName)
account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName)
}
}
},
@@ -208,7 +208,7 @@ fun ConcordChannelListScreen(
confirmButton = {
TextButton(onClick = {
channelToDelete = null
scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) }
scope.launch { account.concord.deleteConcordChannel(communityId, id, target.initialName) }
}) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm))
}
@@ -254,7 +254,7 @@ fun ConcordChannelListScreen(
minting = true
scope.launch {
try {
inviteLink = account.mintConcordInvite(communityId)
inviteLink = account.concord.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
@@ -596,7 +596,7 @@ private fun ConcordFileUploadDialog(
onceUploaded = { uploads ->
val imetas = uploads.mapNotNull { it.toConcordImeta() }
if (imetas.isNotEmpty()) {
accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas)
accountViewModel.account.concord.sendConcordChannelImageMessage(community, channel, "", imetas)
}
onUpload()
},
@@ -120,7 +120,7 @@ fun ConcordCreateScreen(
scope.launch {
val communityId =
try {
accountViewModel.account.createConcordCommunity(
accountViewModel.account.concord.createConcordCommunity(
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
relays = relays.map { it.url },
@@ -163,7 +163,7 @@ fun ConcordEditScreen(
scope.launch {
val ok =
try {
account.editConcordMetadata(
account.concord.editConcordMetadata(
communityId = communityId,
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
@@ -116,7 +116,7 @@ fun ConcordInviteScreen(
LaunchedEffect(link, state) {
if (state is RedeemState.Working) {
state =
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
when (val result = accountViewModel.account.concord.joinConcordViaInvite(link)) {
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
is ConcordInviteResult.InvalidLink ->
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
@@ -50,7 +50,7 @@ fun ConcordChannelPreviewLoader(
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return@LaunchedEffect
account.warmConcordChannelPreviews(listOf(entry))
account.concord.warmConcordChannelPreviews(listOf(entry))
}
}
@@ -72,6 +72,6 @@ fun ConcordChannelPreviewAccountPreload(accountViewModel: AccountViewModel) {
LaunchedEffect(communities, revision) {
// Debounce the cold-boot burst of fold revisions (and any join/leave churn) into one drain.
delay(1500)
account.warmConcordChannelPreviews(communities)
account.concord.warmConcordChannelPreviews(communities)
}
}
@@ -150,7 +150,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) {
// (1) Load + membership/epoch change: one complete sweep of the whole set.
LaunchedEffect(sig) {
if (communities.isNotEmpty()) account.syncConcordControlPlanes(communities)
if (communities.isNotEmpty()) account.concord.syncConcordControlPlanes(communities)
}
// (2) Reconnect: re-sweep when a relay of ours transitions disconnected → connected.
@@ -174,7 +174,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) {
val now = TimeUtils.nowMillis()
if (now - lastSweep < RECONNECT_RESWEEP_MIN_INTERVAL_MS) return@collect
lastSweep = now
account.syncConcordControlPlanes(liveCommunities)
account.concord.syncConcordControlPlanes(liveCommunities)
}
}
}
@@ -204,11 +204,11 @@ open class ConcordNewMessageViewModel : ViewModel() {
val editing = editingMessage.value
if (editing != null) {
account.editConcordChannelMessage(editing, text)
account.concord.editConcordChannelMessage(editing, text)
editingMessage.value = null
} else {
val parent = replyTo.value
account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
account.concord.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
}
message.clearText()
@@ -254,7 +254,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
val geohashes = parseGeohashes()
val existing = channel
if (existing == null) {
account.createRelayGroup(
account.relayGroups.createRelayGroup(
relay = relay!!,
groupId = groupId,
name = name,
@@ -270,7 +270,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null,
)
} else {
account.editRelayGroupMetadata(
account.relayGroups.editRelayGroupMetadata(
channel = existing,
name = name,
about = about,
@@ -567,7 +567,7 @@ open class ChannelNewMessageViewModel :
val pk = user.pubkeyHex
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
try {
accountViewModel.account.putRelayGroupUser(channel, pk, emptyList())
accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList())
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}")
@@ -509,13 +509,13 @@ private fun SendPaymentLoaded(
if (onchainAddressTarget != null) {
// Pays the profile's announced bitcoin address directly —
// a plain wallet send, no NIP-BC receipt exists for it.
accountViewModel.account.sendOnchainToAddress(
accountViewModel.account.zaps.sendOnchainToAddress(
recipientAddress = onchainAddressTarget,
amountSats = amount,
feeRateSatPerVByte = feeRate,
)
} else {
accountViewModel.account.sendOnchainZap(
accountViewModel.account.zaps.sendOnchainZap(
recipientPubKey = user.pubkeyHex,
amountSats = amount,
feeRateSatPerVByte = feeRate,
@@ -268,7 +268,7 @@ class SearchBarViewModel(
}
if (term.isBlank()) return@combine emptyList<User>()
val users = LocalCache.findUsersStartingWith(term, account)
val users = LocalCache.search.findUsersStartingWith(term, account)
if (follows != null) users.filter { it.pubkeyHex in follows } else users
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -285,7 +285,7 @@ class SearchBarViewModel(
) { term, _, currentScope, order, follows ->
if (currentScope == SearchScope.PEOPLE) return@combine emptyList()
val raw = LocalCache.findNotesStartingWith(term, account.hiddenUsers)
val raw = LocalCache.search.findNotesStartingWith(term, account.hiddenUsers)
val filtered = if (follows != null) raw.filter { it.author?.pubkeyHex in follows } else raw
when (order) {
@@ -317,7 +317,7 @@ class SearchBarViewModel(
invalidations,
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findPublicChatChannelsStartingWith(term)
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findPublicChatChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -327,7 +327,7 @@ class SearchBarViewModel(
invalidations,
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(term)
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findEphemeralChatChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -337,7 +337,7 @@ class SearchBarViewModel(
invalidations,
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(term)
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findLiveActivityChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -425,7 +425,7 @@ fun OnchainZapSendDialog(
)
return@launch
}
accountViewModel.account.sendOnchainZapWithSplits(
accountViewModel.account.zaps.sendOnchainZapWithSplits(
recipients = shares,
feeRateSatPerVByte = feeRate,
comment = comment.trim(),
@@ -433,7 +433,7 @@ fun OnchainZapSendDialog(
)
} else {
val recipient = resolvedRecipient ?: return@launch
accountViewModel.account.sendOnchainZap(
accountViewModel.account.zaps.sendOnchainZap(
recipientPubKey = recipient,
amountSats = amount,
feeRateSatPerVByte = feeRate,
@@ -347,7 +347,7 @@ class ReloadMintViewModel : ViewModel() {
// Fire-and-forget: the mint-quote poll below is the source of truth for
// whether the payment actually landed.
runCatching {
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
}
} else {
// No NWC — surface the invoice for an external wallet and keep polling.
@@ -209,7 +209,7 @@ class TopUpMintViewModel : ViewModel() {
// Fire-and-forget: the mint-quote poll below is the source of truth for
// whether the payment actually landed.
runCatching {
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
}
} else {
// No NWC — surface the invoice for an external wallet and keep polling.
@@ -222,7 +222,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
delay(NWC_TIMEOUT_MS)
val requestId = requestIdProvider()
val spoofs = requestId?.let { account?.nwcSpoofAttempts(it) ?: 0 } ?: 0
val spoofs = requestId?.let { account?.zaps?.nwcSpoofAttempts(it) ?: 0 } ?: 0
_error.value =
if (spoofs > 0) {
"Wallet request timed out — $spoofs ${if (spoofs == 1) "reply was" else "replies were"} rejected because " +
@@ -230,7 +230,7 @@ class WalletViewModel : ViewModel() {
} else {
"Wallet request timed out"
}
requestId?.let { account?.cleanupNwcRequest(it) }
requestId?.let { account?.zaps?.cleanupNwcRequest(it) }
onTimeout()
}
@@ -406,7 +406,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
updateWalletInfo(walletId) { it.copy(isLoading = true, error = null) }
try {
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
when (response) {
is GetBalanceSuccessResponse -> {
val sats = (response.result?.balance ?: 0L) / 1000L
@@ -437,7 +437,7 @@ class WalletViewModel : ViewModel() {
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
when (response) {
is GetInfoSuccessResponse -> {
updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) }
@@ -479,7 +479,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
timeoutJob.cancel()
when (response) {
is GetBalanceSuccessResponse -> {
@@ -512,7 +512,7 @@ class WalletViewModel : ViewModel() {
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
when (response) {
is GetInfoSuccessResponse -> {
_walletAlias.value = response.result?.alias
@@ -538,7 +538,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
@@ -591,7 +591,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoadingMore.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
@@ -638,7 +638,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_sendState.value = SendState.Sending
try {
acc.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
when (response) {
is PayInvoiceSuccessResponse -> {
_sendState.value = SendState.Success(response.result?.preimage)
@@ -676,7 +676,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_receiveState.value = ReceiveState.Creating
try {
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
MakeInvoiceMethod.create(
amount = amountSats * 1000L,
@@ -2024,14 +2024,105 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d transmiter</item>
<item quantity="few">%1$s \u00b7 %2$d transmiterów</item>
<item quantity="many">%1$s \u00b7 %2$d transmiterów</item>
<item quantity="other">%1$s \u00b7 %2$d transmitery</item>
</plurals>
<string name="relay_purpose_browsing">Przeglądanie</string>
<string name="relay_purpose_media">Multimedia</string>
<string name="relay_purpose_tags">Hashtagi</string>
<string name="relay_purpose_topics">Tematy</string>
<string name="relay_purpose_thread">Rozmowa</string>
<string name="relay_purpose_search">Szukaj</string>
<string name="relay_purpose_referenced">Wyszukiwanie brakujących wydarzeń</string>
<string name="relay_purpose_engagement">Obserwowanie wydarzeń</string>
<string name="relay_explain_referenced">Pobiera wydarzenia na podstawie identyfikatora, do których odnosi się jakiś element na ekranie, ale których jeszcze nie masz — cytat, wiadomość nadrzędna odpowiedzi, początek wątku.</string>
<string name="relay_explain_engagement">Monitoruje aktualnie wyświetlane wydarzenia pod kątem nowych odpowiedzi, reakcji, udostępnień, zapsów i zgłoszeń, dzięki czemu liczby są aktualizowane na bieżąco podczas czytania.</string>
<string name="relay_purpose_add_ons">Dodatki</string>
<string name="relay_purpose_relay_info">Informacje o transmiterze</string>
<string name="relay_purpose_other">Inne</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Wyszukiwarka listy transmiterów</string>
<string name="relay_purpose_observing_profiles">Obserwowanie profili</string>
<string name="relay_purpose_your_account">Dane konta</string>
<string name="relay_purpose_home_feed">Główny kanał</string>
<string name="relay_purpose_relay_groups">Grupy Transmiterów</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d grupa</item>
<item quantity="few">%1$d grup</item>
<item quantity="many">%1$d grup</item>
<item quantity="other">%1$d grupy</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Czaty efemeryczne</string>
<string name="relay_purpose_geohash_chats">Czaty z funkcją lokalizacji</string>
<string name="relay_purpose_live_chat">Czat podczas transmisji na żywo</string>
<string name="relay_explain_relay_groups">Grupy NIP-29, do których dołączyłeś. Każda grupa działa na jednym transmiterze, więc aplikacja łączy się z każdym transmiterem, na którym znajduje się jakaś Twoja grupa.</string>
<string name="relay_explain_ephemeral_chats">Czaty, w których nie jest zapisywana historia — wiadomości są dostępne tylko wtedy, gdy użytkownik jest podłączony, więc aby otrzymywać jakiekolwiek wiadomości, należy pozostać subskrybentem.</string>
<string name="relay_explain_geohash_chats">Pokoje powiązane z lokalizacją dla obszarów, które obserwujesz, wymagane od transmiterów, które je obsługują.</string>
<string name="relay_explain_live_chat">Czatuj i zap cele powiązane z transmisjami na żywo, które masz otwarte lub które obserwujesz.</string>
<string name="relay_purpose_dm_inbox">Skrzynka odbiorcza DM</string>
<string name="relay_purpose_your_wallet">Portfel</string>
<string name="relay_purpose_nutzap_inbox">Skrzynka odbiorcza Nutzap</string>
<string name="relay_purpose_mint_directory">Katalog Mint</string>
<string name="relay_purpose_nwc">Podłącz portfel</string>
<string name="relay_purpose_community_chats">Czaty społecznościowe</string>
<string name="relay_purpose_community_feeds">Kanały społecznościowe</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Twoje transmitery skrzynek odbiorczych oraz niewielka, zmieniająca się próbka transmiterów, na których publikują osoby, które obserwujesz na wypadek, gdyby wzmianka została dostarczona gdzie indziej.</string>
<string name="relay_explain_direct_messages">Transmitery skrzynek odbiorczych DM, gdzie dostarczane są zawijane wiadomości DM.</string>
<string name="relay_explain_public_chats">Transmiter domowy każdego czatu, który masz otwarty lub do którego dołączyłeś.</string>
<string name="relay_explain_community_chats">Transmitery, na których każda społeczność publikuje swoje plany.</string>
<string name="relay_explain_encrypted_groups">Wiadomości grupowe i pakiety kluczy na transmiterach poszczególnych grup.</string>
<string name="relay_explain_live_rooms">Transmitery pokoju, dopóki jest otwarty.</string>
<string name="relay_explain_account_data">Twój profil, ustawienia i wersje robocze na Twoich domowych transmiterach.</string>
<string name="relay_explain_profiles">Profile osób znajdujących się obecnie na ekranie.</string>
<string name="relay_explain_relay_lists">Ustala, na jakich transmiterach poszczególne osoby publikują swoje treści, dzięki czemu ich posty mogą być pobierane z właściwego miejsca.</string>
<string name="relay_explain_follows">Listy obserwacji, używane do budowania Twojego kanału i Twojej sieci WoT.</string>
<string name="relay_explain_moderation">Raporty sporządzone przez obserwowanych przez Ciebie użytkowników na temat profili wyświetlanych obecnie na ekranie, uzyskane z poszczególnych transmiterów, na których publikują ci użytkownicy.</string>
<string name="relay_purpose_reports_from_follows">Zgłoszenia od obserwujących</string>
<string name="relay_explain_wallet">Własne zdarzenia z portfela, odczytane z transmiterów, na których zostały opublikowane.</string>
<string name="relay_explain_nutzap_inbox">Monitoruje transmitery Nutzap, a także transmitery skrzynek odbiorczych i wiadomości prywatnych, dzięki czemu żadna płatność nie umknie.</string>
<string name="relay_explain_mint_directory">Sprawdza na różnych transmiterach, na których istnieją serwisy typu „mint”, oraz które z nich są polecane przez użytkowników.</string>
<string name="relay_explain_nwc">Powiadomienia z podłączonego portfela.</string>
<string name="active_subs_title">Aktywne subskrypcje transmitera</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d filtr</item>
<item quantity="few">%1$d filtrów</item>
<item quantity="many">%1$d filtrów</item>
<item quantity="other">%1$d filtry</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d transmiter</item>
<item quantity="few">%1$d transmiterów</item>
<item quantity="many">%1$d/ transmiterów</item>
<item quantity="other">%1$d transmitery</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr nie został jeszcze przypisany</item>
<item quantity="few">%1$d filtrów nie zostało jeszcze przypisanych</item>
<item quantity="many">%1$d filtrów nie zostało jeszcze przypisanych</item>
<item quantity="other">%1$d filtry nie zostały jeszcze przypisane</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Nie przypisano do konta</string>
<string name="active_subs_no_entity">Wszystkie</string>
<string name="active_subs_scope_global">Wszyscy</string>
<string name="active_subs_scope_follows">Osoby, które obserwujesz</string>
<string name="active_subs_scope_authors">Wybrana lista osób</string>
<string name="active_subs_scope_muted">Uciszone osoby</string>
<string name="active_subs_scope_all_communities">Twoje społeczności</string>
<string name="active_subs_scope_algo">Ulubiony kanał algorytmów</string>
<string name="active_subs_share">%1$d%% z wszystkich</string>
<string name="active_subs_search_keywords">subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka</string>
<string name="relay_explain_home">Posty osób, które obserwujesz, są pobierane z transmiterów, na których każda z nich publikuje swoje treści.</string>
<string name="always_on_notif_connecting">Łączenie z transmiterami odbiorczymi\u2026</string>
<string name="always_on_notif_setting_title">Usługa powiadomień zawsze włączona</string>
<string name="always_on_notif_setting_description">Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości.</string>
@@ -1467,7 +1467,7 @@ class AmethystAppFunctions {
}
val result =
account.sendOnchainZap(
account.zaps.sendOnchainZap(
recipientPubKey = recipientPub,
amountSats = sats,
feeRateSatPerVByte = feeRateSatPerVByte,
@@ -1751,7 +1751,7 @@ class AmethystAppFunctions {
val deferred = CompletableDeferred<Response?>()
// sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies
// (success, error, or NwcError). On timeout we discard the late response.
account.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
if (!deferred.isCompleted) deferred.complete(response)
}
val response =
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Dao
import com.vitorpamplona.amethyst.model.LocalCache.getOrCreateAddressableNoteInternal
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
@@ -39,9 +39,13 @@ class HexBenchmark {
@get:Rule val r = BenchmarkRule()
val hex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf"
val hex128 = hex + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
val bytes =
fr.acinq.secp256k1.Hex
.decode(hex)
val bytes64 =
fr.acinq.secp256k1.Hex
.decode(hex128)
@Test
fun hexIsEqual() {
@@ -103,4 +107,40 @@ class HexBenchmark {
fun isHex64() {
r.measureRepeated { Hex.isHex64(hex) }
}
@Test
fun hexDecode64() {
r.measureRepeated { Hex.decode64(hex) }
}
@Test
fun hexDecode64OrNull() {
r.measureRepeated { Hex.decode64OrNull(hex) }
}
@Test
fun hexEncode64() {
r.measureRepeated { Hex.encode64(bytes) }
}
@Test
fun hexDecode128() {
r.measureRepeated { Hex.decode128(hex128) }
}
@Test
fun hexEncode128() {
r.measureRepeated { Hex.encode128(bytes64) }
}
/** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */
@Test
fun hexIsHex64ThenDecode() {
r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null }
}
@Test
fun hexToLong256() {
r.measureRepeated { Hex.toLong256(hex) }
}
}
@@ -65,6 +65,7 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@@ -210,8 +211,12 @@ class Context(
* (auth-required / rate-limited / restricted / ), and NIP-42 AUTH
* challenges so a failed REQ can be explained instead of guessed at.
* Registered on [client] for the life of this run.
*
* Quartz's [RelayObserver], which also measures the connect/read/write
* round trips behind that feedback and is what a [RelayMonitor] publishes
* as NIP-66. One listener now answers both questions.
*/
val relayDiagnostics: RelayDiagnostics = RelayDiagnostics().also { client.addConnectionListener(it) }
val relayDiagnostics: RelayObserver = RelayObserver().also { client.addConnectionListener(it) }
/**
* Adaptive per-relay concurrent-subscription cap. Starts every relay
@@ -1,96 +0,0 @@
/*
* 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.cli
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
* Client-wide tally of the relay feedback the crawl would otherwise never see:
* `NOTICE` frames, `CLOSED` reasons (`auth-required` / `rate-limited` /
* `restricted` / ), and NIP-42 `AUTH` challenges. Registered as a
* [RelayConnectionListener] on the shared client, so every incoming message
* during a run is counted and a REQ failure can be explained instead of
* guessed at.
*
* Callbacks fire on the per-relay socket threads, so all state is concurrent.
*/
class RelayDiagnostics : RelayConnectionListener {
private val closedByReason = ConcurrentHashMap<String, AtomicLong>()
private val noticeSamples = ConcurrentHashMap<String, AtomicLong>()
private val authChallenges = AtomicLong()
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
when (msg) {
// CLOSED reasons follow the NIP-01 machine-readable "word: text"
// convention, so the prefix categorises the failure.
is ClosedMessage -> bump(closedByReason, prefix(msg.message))
// NOTICE is free-form; keep the (truncated) text so recurring
// relay complaints ("too many concurrent REQs", …) are visible.
is NoticeMessage -> if (noticeSamples.size < MAX_DISTINCT_NOTICES) bump(noticeSamples, msg.message.trim().take(80))
is AuthMessage -> authChallenges.incrementAndGet()
else -> Unit
}
}
private fun bump(
map: ConcurrentHashMap<String, AtomicLong>,
key: String,
) {
map.getOrPut(key) { AtomicLong() }.incrementAndGet()
}
/** The NIP-01 machine-readable prefix (`word` before `:`), or `other`. */
private fun prefix(message: String): String {
val head = message.substringBefore(':').trim().lowercase()
return head.ifEmpty { "other" }.take(24)
}
fun hadFeedback(): Boolean = authChallenges.get() > 0 || closedByReason.isNotEmpty() || noticeSamples.isNotEmpty()
/** JSON-friendly summary for the command output. */
fun snapshot(): Map<String, Any?> =
mapOf(
"auth_challenges" to authChallenges.get(),
"closed_by_reason" to closedByReason.entries.associate { it.key to it.value.get() }.toSortedMap(),
"notices" to noticeSamples.values.sumOf { it.get() },
"notice_top" to
noticeSamples.entries
.sortedByDescending { it.value.get() }
.take(TOP_NOTICES)
.map { "${it.key} (${it.value.get()})" },
)
companion object {
private const val MAX_DISTINCT_NOTICES = 500
private const val TOP_NOTICES = 8
}
}
@@ -129,7 +129,7 @@ object GrapeRankCrawl {
/** Echo any relay NOTICE/CLOSED feedback + adaptive throttling the crawl saw. */
internal fun reportRelayFeedback(ctx: Context) {
if (ctx.relayDiagnostics.hadFeedback()) {
System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.snapshot()}")
System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.summary()}")
}
if (ctx.relayLimiter.hadThrottling()) {
System.err.println("[graperank] relay throttling: ${ctx.relayLimiter.snapshot()}")
@@ -204,7 +204,7 @@ object GrapeRankCrawl {
"observer" to observer,
"crawl_rounds" to stats.rounds,
"relays_contacted" to stats.relaysContacted,
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null,
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.summary() else null,
"relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null,
"max_hop_reached" to (stats.hopHistogram.keys.maxOrNull() ?: 0),
"users_by_hop" to stats.hopHistogram.mapKeys { it.key.toString() },
@@ -191,7 +191,7 @@ object GrapeRankScore {
"observer" to observer,
"crawl_rounds" to (crawlStats?.rounds ?: 0),
"relays_contacted" to (crawlStats?.relaysContacted ?: 0),
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null,
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.summary() else null,
"relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null,
"max_hop_reached" to (hopHistogram.keys.maxOrNull() ?: 0),
"users_by_hop" to hopHistogram.mapKeys { it.key.toString() },
@@ -41,7 +41,19 @@ import kotlinx.coroutines.launch
* - Outbound (`send`) server `RelaySession.receive()` via an inbound
* channel drained by a single coroutine, preserving message order
* per the [WebSocketListener] contract.
* - Server-side `send` callbacks [WebSocketListener.onMessage].
* - Server-side `send` callbacks [WebSocketListener.onMessage], via an
* outbound channel drained by a single coroutine started only after
* [WebSocketListener.onOpen] has fired.
*
* The outbound channel is not an optimization: a session's connect-time
* policies send synchronously from inside `server.connect` (e.g.
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy]'s
* AUTH challenge), which is before this socket has stored its own state and
* before `onOpen`. Delivering those frames directly would break the
* [WebSocketListener] contract (no `onMessage` before `onOpen`) and worse
* a listener that answers the challenge from another thread (RelayAuthenticator
* signs and replies concurrently) could hit [send] while `incoming` is still
* null, silently losing the reply and deadlocking the NIP-42 handshake.
*
* Use this to wire a `NostrClient` to an embedded server in unit tests
* or single-JVM scenarios without paying for a real TCP socket. Because
@@ -49,8 +61,8 @@ import kotlinx.coroutines.launch
* expects.
*
* Reconnect-after-disconnect is supported: each [connect] creates a
* fresh scope + drain channel so a previous [disconnect] (which
* cancels both) doesn't leave a dead drainer behind.
* fresh scope + drain channels so a previous [disconnect] (which
* cancels them) doesn't leave a dead drainer behind.
*/
class InProcessWebSocket(
private val server: NostrServer,
@@ -58,7 +70,9 @@ class InProcessWebSocket(
) : WebSocket {
private var scope: CoroutineScope? = null
private var incoming: Channel<String>? = null
private var outgoing: Channel<String>? = null
private var drainJob: Job? = null
private var deliverJob: Job? = null
private var session: RelaySession? = null
override fun needsReconnect(): Boolean = session == null
@@ -67,10 +81,12 @@ class InProcessWebSocket(
if (session != null) return
val newScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val newIncoming = Channel<String>(UNLIMITED)
val s = server.connect { json -> out.onMessage(json) }
val newOutgoing = Channel<String>(UNLIMITED)
val s = server.connect { json -> newOutgoing.trySend(json) }
scope = newScope
incoming = newIncoming
outgoing = newOutgoing
session = s
drainJob =
newScope.launch {
@@ -80,6 +96,15 @@ class InProcessWebSocket(
}
out.onOpen(0, false)
// Started only after onOpen so every buffered connect-time frame (AUTH
// challenge & co.) reaches the listener with the socket fully wired.
deliverJob =
newScope.launch {
for (msg in newOutgoing) {
out.onMessage(msg)
}
}
}
override fun disconnect() {
@@ -87,7 +112,10 @@ class InProcessWebSocket(
session = null
incoming?.close()
incoming = null
outgoing?.close()
outgoing = null
drainJob = null
deliverJob = null
scope?.cancel()
scope = null
s.close()
@@ -0,0 +1,161 @@
/*
* 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.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.concurrent.Volatile
/**
* NIP-66 relay monitoring for a client that was going to talk to relays anyway.
*
* Construct one, and from then on every connection the client makes is measured
* ([RelayObserver]), signed and stored as a kind:30166 ([RelayReachabilityStore])
* on an interval, and folded back into a cheap [isKnownDead] the caller consults
* when it picks relays. There is nothing else to wire.
*
* ## Reading is the cheap side, and it has to be
*
* A relay picker runs per event thousands of times a second in an outbox
* fan-out so [isKnownDead] answers from an in-memory snapshot refreshed on the
* same interval as the writes, never from a store query. The store round trip
* happens [refreshIntervalMs] apart, not per routing decision.
*
* ## The signer is required
*
* Measuring relay quality and letting others check it IS NIP-66; a monitor that
* cannot sign is not a monitor. Making the signer optional would also create the
* failure this library keeps trying to design out a component configured,
* silent, and doing nothing. A client that should not publish simply does not
* construct one of these, which is a decision visible where it is made.
*
* Note that a monitor is its own identity: per NIP-66 it has its own pubkey,
* profile and relay list, distinct from any user account the client also holds.
*
* ## What ends up in the record
*
* Only what was observed connect, read and write round trips, whether the
* relay actually demanded AUTH, and the network type implied by the url. Nothing
* is copied out of a relay's NIP-11 document: that is the relay's own claim
* about itself, available to anyone who asks, and re-publishing it under a
* monitor's signature would add nothing but an opportunity to go stale.
*/
class RelayMonitor(
private val client: INostrClient,
store: IEventStore,
private val scope: CoroutineScope,
signer: NostrSigner,
ttlSeconds: Long = RelayReachabilityStore.DEFAULT_TTL_SECONDS,
private val flushIntervalMs: Long = DEFAULT_FLUSH_INTERVAL_MS,
private val refreshIntervalMs: Long = DEFAULT_REFRESH_INTERVAL_MS,
private val onError: (String) -> Unit = {},
) : AutoCloseable {
val observer = RelayObserver()
private val reachability = RelayReachabilityStore(store, signer, ttlSeconds)
@Volatile private var snapshot: RelayReachabilityStore.Snapshot? = null
init {
client.addConnectionListener(observer)
scope.launch { flushLoop() }
scope.launch { refreshLoop() }
}
/**
* Skip this relay? Answers from memory, so it is safe to call per routing
* decision. False until the first [refresh] completes an unknown relay is
* one to try, never one to shun.
*/
fun isKnownDead(relay: NormalizedRelayUrl): Boolean = snapshot?.isKnownDead(relay) == true
/** Relays proven unreachable within the TTL and not seen live since. */
fun deadSet(): Set<NormalizedRelayUrl> = snapshot?.dead ?: emptySet()
/** Relays with a recent successful open, from any monitor whose records we hold. */
fun liveSet(): Set<NormalizedRelayUrl> = snapshot?.live ?: emptySet()
/** Re-read the reachability records, including any other monitor's that arrived. */
suspend fun refresh() {
runCatching { snapshot = reachability.snapshot() }
.onFailure { onError("could not read relay reachability: ${it.message}") }
}
/**
* Sign and store what has been observed since the last flush. Returns how
* many records were written.
*
* A relay whose state has not changed is skipped: re-writing its record
* would refresh a freshness window that nothing re-measured.
*/
suspend fun flush(): Int {
val fresh = observer.collectUnreported()
if (fresh.isEmpty()) return 0
return runCatching { reachability.record(fresh, TimeUtils.now()) }
.onFailure { onError("could not write relay reachability: ${it.message}") }
.getOrDefault(0)
}
private suspend fun flushLoop() {
while (scope.isActive) {
delay(flushIntervalMs)
flush()
}
}
private suspend fun refreshLoop() {
// Immediately, then on the interval: the first thing a run should know is
// what the last one learned, before it dials anything.
refresh()
while (scope.isActive) {
delay(refreshIntervalMs)
refresh()
}
}
/**
* Detach and stop measuring. Does NOT flush the last write needs a
* coroutine and a bound on how long a shutdown may block, both of which
* belong to the caller. Call [flush] inside your own timeout first.
*/
override fun close() {
runCatching { client.removeConnectionListener(observer) }
}
companion object {
/**
* Five minutes: long enough that a flapping relay does not mint a record
* per flap, short enough that a crash loses little. The records are
* replaceable, so writing again costs one document, not one more.
*/
const val DEFAULT_FLUSH_INTERVAL_MS = 5 * 60 * 1000L
/** How often the in-memory dead/live view is re-read from the store. */
const val DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1000L
}
}
@@ -0,0 +1,351 @@
/*
* 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.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlin.concurrent.Volatile
import kotlin.time.TimeSource
/**
* What a client learns about relays just by talking to them.
*
* A NIP-66 monitor normally probes: it opens connections for the sole purpose of
* measuring, and then throws them away. A client that is already subscribing,
* fetching and publishing has better data available for free measured under
* real load, against the relays it actually uses, at the concurrency it actually
* runs. Attached to a client as a [RelayConnectionListener], this collects it.
*
* Everything here is **observed**. Nothing is copied from a relay's NIP-11
* document, and that is deliberate: a relay's self-description is available to
* anyone who asks for it, so republishing it under a monitor's signature adds
* nothing but a chance to be stale. Where the two disagree a relay that
* advertises open reads and then sends AUTH the observation is the half worth
* having, and copying the claim would erase it.
*
* ## Threading
*
* Callbacks for one relay arrive on that relay's own socket thread, so a single
* [Observation] is only ever written by one thread. The fields are `@Volatile`
* for visibility to the reader that publishes them, not for mutual exclusion,
* and the counters need no atomics. The client-wide tallies ARE shared and use
* [ConcurrentMap.merge].
*
* ## Why nothing is removed
*
* Observations are marked reported rather than deleted. A long-lived connection
* only fires `onConnected` once, so a measurement that vanished when it was
* published would leave the relays we know best an upstream whose socket has
* been open for hours with nothing to say about them ever again. The map is
* bounded by the number of distinct relays the client has ever dialled.
*/
class RelayObserver : RelayConnectionListener {
class Observation(
val url: NormalizedRelayUrl,
) {
// Monotonic marks, not wall clock: these measure durations, and a clock
// step mid-connection must not produce a negative or wild latency.
@Volatile var connectingAt: TimeSource.Monotonic.ValueTimeMark? = null
@Volatile var rttOpenMs: Long? = null
@Volatile var firstReqAt: TimeSource.Monotonic.ValueTimeMark? = null
@Volatile var rttReadMs: Long? = null
@Volatile var firstEventAt: TimeSource.Monotonic.ValueTimeMark? = null
@Volatile var rttWriteMs: Long? = null
/** It opened, or served something. Nothing more is claimed by this. */
@Volatile var reachable: Boolean = false
/** Why it did not open, verbatim from the transport. */
@Volatile var error: String? = null
/** It sent AUTH, or CLOSED a subscription demanding it. Measured, not read off NIP-11. */
@Volatile var authRequired: Boolean = false
/** The NIP-01 machine-readable prefix of the last CLOSED. */
@Volatile var closedReason: String? = null
/** The last NOTICE text, truncated — often the only explanation a relay gives. */
@Volatile var notice: String? = null
/** Set by every observation, cleared when published. See the class doc. */
@Volatile var unreported: Boolean = false
internal fun touch() {
unreported = true
}
}
private val seen = ConcurrentMap<NormalizedRelayUrl, Observation>()
// Client-wide tallies, across every relay. Separate from the per-relay state
// because they answer a different question — "how did this run go" rather
// than "what shall I record about this relay" — and because a summary must
// survive publishing, which clears the per-relay flags.
private val closedByReason = ConcurrentMap<String, Long>()
private val noticeSamples = ConcurrentMap<String, Long>()
private val authChallenges = ConcurrentMap<String, Long>()
private fun of(relay: IRelayClient) = seen.getOrPut(relay.url) { Observation(relay.url) }
override fun onConnecting(relay: IRelayClient) {
val o = of(relay)
o.connectingAt = TimeSource.Monotonic.markNow()
// Cleared, not kept: a reconnect is a fresh attempt, and carrying an old
// error forward would report a working relay as broken for as long as the
// process lives after one bad minute.
o.error = null
o.touch()
}
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
val o = of(relay)
o.reachable = true
o.error = null
o.connectingAt?.let { o.rttOpenMs = it.elapsedNow().inWholeMilliseconds.coerceAtLeast(0) }
o.touch()
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
val o = of(relay)
// NOT `reachable = false`. A relay that answered an hour ago and is down
// now is a different thing from one that never answered at all, and only
// the writer decides which record that becomes.
o.error = errorMessage.take(MAX_TEXT)
o.touch()
}
/**
* Outgoing commands start the read and write clocks the FIRST of each per
* relay, since a later REQ on a warm socket measures nothing about the relay.
*/
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
if (!success) return
val o = of(relay)
when (cmd) {
is ReqCmd -> if (o.firstReqAt == null) o.firstReqAt = TimeSource.Monotonic.markNow()
is EventCmd -> if (o.firstEventAt == null) o.firstEventAt = TimeSource.Monotonic.markNow()
else -> Unit
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
val o = of(relay)
when (msg) {
is EoseMessage -> {
if (o.rttReadMs == null) {
o.firstReqAt?.let {
o.rttReadMs = it.elapsedNow().inWholeMilliseconds.coerceAtLeast(0)
o.touch()
}
}
}
is OkMessage -> {
if (o.rttWriteMs == null) {
o.firstEventAt?.let {
o.rttWriteMs = it.elapsedNow().inWholeMilliseconds.coerceAtLeast(0)
o.touch()
}
}
}
// Serving an event is proof of life even from a relay that never
// sends EOSE — some do not, and treating those as unresponsive would
// shed relays that work perfectly well. Guarded because this fires
// for EVERY event on every socket: an unconditional write here would
// bounce a cache line between threads to say nothing new.
is EventMessage -> {
if (!o.reachable) {
o.reachable = true
o.touch()
}
}
is AuthMessage -> {
o.authRequired = true
o.touch()
authChallenges.merge(relay.url.url, 1L) { a, b -> a + b }
}
is NoticeMessage -> {
val text = msg.message.trim().take(NOTICE_KEY)
o.notice = text
o.touch()
if (noticeSamples.size() < MAX_DISTINCT_NOTICES) noticeSamples.merge(text, 1L) { a, b -> a + b }
}
is ClosedMessage -> {
val reason = prefixOf(msg.message)
o.closedReason = reason
// NIP-42 refusal, in the shape relays use when the subscription
// is what got rejected rather than the connection.
if (reason == AUTH_REQUIRED) o.authRequired = true
o.touch()
closedByReason.merge(reason, 1L) { a, b -> a + b }
}
else -> Unit
}
}
/**
* Record a measurement taken OUTSIDE the websocket client a TCP probe, a
* DNS failure, a host struck out after repeated silence.
*
* This class is a [RelayConnectionListener], so on its own it can only report
* on relays something opened a websocket to. On a large fan-out that is a
* small minority, and it is the wrong minority: the cheap checks that decide
* NOT to dial are precisely the ones that learn a relay is gone, and their
* findings had nowhere to go. Measured on a 16,507-relay list 104 records
* published, because everything else was ruled out before the client saw it.
*
* A monitor that only reports what it happened to connect to is not a census.
*
* [rttOpenMs] is whatever was actually measured; null means reachable with no
* timing, and no timing is ever invented.
*/
fun record(
relay: NormalizedRelayUrl,
reachable: Boolean,
rttOpenMs: Long? = null,
error: String? = null,
) {
val o = seen.getOrPut(relay) { Observation(relay) }
if (reachable) {
o.reachable = true
o.error = null
// Kept on the Observation, which is never removed — only marked
// reported — so a measurement survives every later flush.
rttOpenMs?.let { o.rttOpenMs = it }
} else {
// Same rule as onCannotConnect: a relay that answered earlier is not
// demoted by one failed probe. The writer decides what record that
// becomes, and "answered, then a probe failed" is not "dead".
o.error = (error ?: "unreachable").take(MAX_TEXT)
}
o.touch()
}
/**
* Everything observed since the last call, marked reported as it is read.
*
* A relay whose state has not changed is left out: writing its record again
* would refresh a freshness window that nothing re-measured.
*/
fun collectUnreported(): List<Observation> =
seen
.snapshot()
.values
.filter { it.unreported }
.onEach { it.unreported = false }
/** Every relay ever observed, whether or not it has changed. */
fun all(): Collection<Observation> = seen.snapshot().values
fun observationOf(relay: NormalizedRelayUrl): Observation? = seen[relay]
fun hadFeedback(): Boolean = authChallenges.size() > 0 || closedByReason.size() > 0 || noticeSamples.size() > 0
/**
* A run-level summary of the feedback relays gave the frames a client
* otherwise never surfaces, so a failed REQ can be explained instead of
* guessed at.
*/
fun summary(): Map<String, Any?> {
val notices = noticeSamples.snapshot()
return mapOf(
"auth_challenges" to authChallenges.snapshot().values.sum(),
"auth_required_relays" to authChallenges.size(),
// Sorted into a LinkedHashMap rather than toSortedMap(): that one is
// java.util and this file is commonMain, so it built on JVM and broke
// the native targets.
"closed_by_reason" to
closedByReason
.snapshot()
.entries
.sortedBy { it.key }
.associate { it.key to it.value },
"notices" to notices.values.sum(),
"notice_top" to
notices.entries
.sortedByDescending { it.value }
.take(TOP_NOTICES)
.map { "${it.key} (${it.value})" },
)
}
companion object {
private const val MAX_TEXT = 200
private const val NOTICE_KEY = 80
private const val MAX_DISTINCT_NOTICES = 500
private const val TOP_NOTICES = 8
private const val AUTH_REQUIRED = "auth-required"
private const val OTHER = "other"
private const val MAX_PREFIX = 24
/**
* The NIP-01 machine-readable prefix the word before `:` or `other`.
*
* The colon is required. `substringBefore` returns the WHOLE string when
* the separator is absent, so without this check a relay's free-form
* CLOSED prose became its own tally key and the map's cardinality grew
* with the number of distinct sentences relays happened to write.
*/
fun prefixOf(message: String): String {
if (!message.contains(':')) return OTHER
val head = message.substringBefore(':').trim().lowercase()
return head.ifEmpty { OTHER }.take(MAX_PREFIX)
}
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.networkType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.requirement
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.rtt
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType
@@ -157,6 +158,53 @@ class RelayReachabilityStore(
for (relay in dead) if (relay !in reachableRttMs) writeOne(relay, up = false, now, 0)
}
/**
* Write everything a run observed, one replaceable record per relay.
*
* Skips a relay it learned nothing about one that was never dialled, or
* only started connecting. Silence is not evidence, and a record written on
* no observation would refresh a freshness window nothing re-measured.
*
* Returns the number of records written.
*/
suspend fun record(
observations: Collection<RelayObserver.Observation>,
now: Long = TimeUtils.now(),
): Int {
var written = 0
for (o in observations) {
if (!o.reachable && o.error == null) continue
writeObserved(o, now)
written++
}
return written
}
private suspend fun writeObserved(
o: RelayObserver.Observation,
now: Long,
) {
val template =
RelayDiscoveryEvent.build(o.url, createdAt = now) {
networkType(networkTypeOf(o.url))
if (o.reachable) {
// Liveness is the presence of rtt-open, per NIP-66. A relay we
// reached without timing the open — served us an event on a
// socket that was already up — still gets the tag so it reads
// as live, but never an invented latency: 0 would be a lie
// aggregators rank on.
o.rttOpenMs?.let { rtt(RttType.OPEN, it) } ?: rtt(RttType.OPEN, 0)
o.rttReadMs?.let { rtt(RttType.READ, it) }
o.rttWriteMs?.let { rtt(RttType.WRITE, it) }
}
// Observed, not read off NIP-11: this relay actually challenged
// us. A relay advertising open reads and then demanding AUTH is
// exactly what a monitor exists to catch.
if (o.authRequired) requirement("auth")
}
store.insert(signer.sign(template))
}
private suspend fun writeOne(
relay: NormalizedRelayUrl,
up: Boolean,
@@ -36,6 +36,8 @@ package com.vitorpamplona.quartz.utils
* val hex = Hex.encode(bytes) // ByteArray -> lower-case hex
* val bytes = Hex.decode(hex) // hex (any case) -> ByteArray
* if (Hex.isHex64(id)) { ... } // is this a valid 32-byte hex id?
* val id = Hex.decode64(idHex) // exactly 64 chars or it throws
* val sig = Hex.decode128OrNull(sigHex) // exactly 128 chars or null
* ```
*/
object Hex {
@@ -183,17 +185,100 @@ object Hex {
require(hex.length and 1 == 0) {
"Invalid hex $hex"
}
return ByteArray(hex.length / 2) {
(hexToByte[hex[2 * it].code] shl 4 or hexToByte[hex[2 * it + 1].code]).toByte()
// table hoisted into a local: the JVM/ART doesn't reliably prove the
// field load loop-invariant, and re-loading it per char costs ~25%
val table = hexToByte
val out = ByteArray(hex.length shr 1)
var c = 0
for (i in out.indices) {
out[i] = ((table[hex[c++].code] shl 4) or table[hex[c++].code]).toByte()
}
return out
}
/**
* Decodes a 32-byte pubkey/event id, accepting only exactly 64 hex chars
* (upper or lower case). Throws [IllegalArgumentException] on any other
* length or on non-hex characters use [decode64OrNull] for untrusted
* input. Single pass: validation is folded into the decode, so this is
* faster than `isHex64` + [decode].
*/
fun decode64(hex: String): ByteArray = decode64OrNull(hex) ?: throw IllegalArgumentException("Invalid 64-char hex $hex")
/** Like [decode64] but returns null instead of throwing. */
fun decode64OrNull(hex: String): ByteArray? = if (hex.length == 64) decodeExactOrNull(hex, 32) else null
/**
* Decodes a 64-byte value (a Schnorr signature), accepting only exactly
* 128 hex chars (upper or lower case). Throws [IllegalArgumentException]
* on any other length or on non-hex characters use [decode128OrNull]
* for untrusted input.
*/
fun decode128(hex: String): ByteArray = decode128OrNull(hex) ?: throw IllegalArgumentException("Invalid 128-char hex $hex")
/** Like [decode128] but returns null instead of throwing. */
fun decode128OrNull(hex: String): ByteArray? = if (hex.length == 128) decodeExactOrNull(hex, 64) else null
/**
* Decodes [hex] into [byteLen] bytes, or null if any char is not a hex
* digit. The caller has already checked `hex.length == 2 * byteLen`.
*
* Tuned at the bytecode level (see `HexBenchmark`): the table is hoisted
* into a local (the JVM/ART can't always prove the field load loop
* invariant), `inline` turns [byteLen] into a compile-time trip count at
* each call site, and validation is branchless the table yields -1 for
* invalid chars and `255 - code` goes negative for chars above 0xFF (e.g.
* emoji, kept in bounds by the `and 0xFF` mask), so OR-ing everything into
* one accumulator and sign-checking it at the end rejects all bad input
* with no branches and no exception table. ~25% faster than the same loop
* with a per-iteration field load and a try/catch guard, and ~2x faster
* than `isHex64` + [decode].
*/
@Suppress("NOTHING_TO_INLINE")
private inline fun decodeExactOrNull(
hex: String,
byteLen: Int,
): ByteArray? {
val table = hexToByte
val out = ByteArray(byteLen)
var acc = 0
var c = 0
for (i in 0 until byteLen) {
val c0 = hex[c++].code
val c1 = hex[c++].code
val b = (table[c0 and 0xFF] shl 4) or table[c1 and 0xFF]
acc = acc or b or (255 - c0) or (255 - c1)
out[i] = b.toByte()
}
return if (acc < 0) null else out
}
/**
* Encodes a 32-byte pubkey/event id as a 64-char lower-case hex string.
* Throws [IllegalArgumentException] when [input] is not exactly 32 bytes.
*/
fun encode64(input: ByteArray): String {
require(input.size == 32) { "Expected 32 bytes, got ${input.size}" }
return encode(input)
}
/**
* Encodes a 64-byte value (a Schnorr signature) as a 128-char lower-case
* hex string. Throws [IllegalArgumentException] when [input] is not
* exactly 64 bytes.
*/
fun encode128(input: ByteArray): String {
require(input.size == 64) { "Expected 64 bytes, got ${input.size}" }
return encode(input)
}
/** Encodes [input] as a lower-case hex string (two chars per byte). */
fun encode(input: ByteArray): String {
val table = byteToHex
val out = CharArray(input.size * 2)
var outIdx = 0
for (i in 0 until input.size) {
val chars = byteToHex[input[i].toInt() and 0xFF]
val chars = table[input[i].toInt() and 0xFF]
out[outIdx++] = (chars shr 8).toChar()
out[outIdx++] = (chars and 0xFF).toChar()
}
@@ -212,23 +297,26 @@ object Hex {
fun readLong(
hex: String,
offset: Int,
): Long =
(hexToByte[hex[offset].code].toLong() shl 60) or
(hexToByte[hex[offset + 1].code].toLong() shl 56) or
(hexToByte[hex[offset + 2].code].toLong() shl 52) or
(hexToByte[hex[offset + 3].code].toLong() shl 48) or
(hexToByte[hex[offset + 4].code].toLong() shl 44) or
(hexToByte[hex[offset + 5].code].toLong() shl 40) or
(hexToByte[hex[offset + 6].code].toLong() shl 36) or
(hexToByte[hex[offset + 7].code].toLong() shl 32) or
(hexToByte[hex[offset + 8].code].toLong() shl 28) or
(hexToByte[hex[offset + 9].code].toLong() shl 24) or
(hexToByte[hex[offset + 10].code].toLong() shl 20) or
(hexToByte[hex[offset + 11].code].toLong() shl 16) or
(hexToByte[hex[offset + 12].code].toLong() shl 12) or
(hexToByte[hex[offset + 13].code].toLong() shl 8) or
(hexToByte[hex[offset + 14].code].toLong() shl 4) or
hexToByte[hex[offset + 15].code].toLong()
): Long {
// table hoisted into a local — one field load instead of sixteen
val t = hexToByte
return (t[hex[offset].code].toLong() shl 60) or
(t[hex[offset + 1].code].toLong() shl 56) or
(t[hex[offset + 2].code].toLong() shl 52) or
(t[hex[offset + 3].code].toLong() shl 48) or
(t[hex[offset + 4].code].toLong() shl 44) or
(t[hex[offset + 5].code].toLong() shl 40) or
(t[hex[offset + 6].code].toLong() shl 36) or
(t[hex[offset + 7].code].toLong() shl 32) or
(t[hex[offset + 8].code].toLong() shl 28) or
(t[hex[offset + 9].code].toLong() shl 24) or
(t[hex[offset + 10].code].toLong() shl 20) or
(t[hex[offset + 11].code].toLong() shl 16) or
(t[hex[offset + 12].code].toLong() shl 12) or
(t[hex[offset + 13].code].toLong() shl 8) or
(t[hex[offset + 14].code].toLong() shl 4) or
t[hex[offset + 15].code].toLong()
}
/**
* Reads the first 64 bits (16 hex chars) of [hex] as a single [Long].
@@ -272,9 +360,10 @@ object Hex {
id: String,
ourId: ByteArray,
): Boolean {
val table = byteToHex
var charIndex = 0
for (i in 0 until ourId.size) {
val chars = byteToHex[ourId[i].toInt() and 0xFF]
val chars = table[ourId[i].toInt() and 0xFF]
if (
id[charIndex++] != (chars shr 8).toChar() ||
id[charIndex++] != (chars and 0xFF).toChar()
@@ -0,0 +1,175 @@
/*
* 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.quartz.nip01Core.relay.server.inprocess
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Pins the [WebSocketListener] contract on the in-process transport against a
* server whose policy sends from inside `onConnect` [FullAuthPolicy] pushes
* its AUTH challenge synchronously while `server.connect` is still on the
* stack, before the socket has stored its own state.
*
* Both tests reproduce (pre-fix, deterministically) the CI-only stall in
* geode's Nip42AuthDmDeliveryTest: the challenge used to be delivered before
* `onOpen` and before `incoming` was assigned, so a listener that answered it
* concurrently (RelayAuthenticator signs on its own coroutine) could call
* [InProcessWebSocket.send] on a half-built socket, get `false`, and lose the
* AUTH reply forever the challenge is dedup'd as already-answered, an EVENT
* rejected `auth-required:` never re-triggers auth, and the DM never lands.
*/
class InProcessWebSocketTest {
private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/")
private fun newServer() =
NostrServer(
store = EventStore(null),
policyBuilder = { FullAuthPolicy(relayUrl) },
)
@Test
fun onOpenPrecedesEveryMessage() =
runTest {
withContext(Dispatchers.Default) {
val server = newServer()
val callbacks = Channel<String>(UNLIMITED)
val listener =
object : WebSocketListener {
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) {
callbacks.trySend("open")
}
override fun onMessage(text: String) {
callbacks.trySend("message")
}
override fun onClosed(
code: Int,
reason: String,
) {
}
override fun onFailure(
t: Throwable,
code: Int?,
response: String?,
) {
}
}
val socket = InProcessWebSocket(server, listener)
try {
socket.connect()
// FullAuthPolicy sends its AUTH challenge at connect time, so both
// callbacks are guaranteed to arrive; the contract is their order.
val received = mutableListOf<String>()
withTimeout(5_000) {
while ("message" !in received) received.add(callbacks.receive())
}
assertEquals(
"open",
received.first(),
"the connect-time AUTH challenge must not be delivered before onOpen; got $received",
)
} finally {
socket.disconnect()
server.close()
}
}
}
@Test
fun replySentFromChallengeHandlerIsNotDropped() =
runTest {
withContext(Dispatchers.Default) {
val server = newServer()
var socket: InProcessWebSocket? = null
val replyAccepted = Channel<Boolean>(UNLIMITED)
val listener =
object : WebSocketListener {
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) {
}
override fun onMessage(text: String) {
// Answer the AUTH challenge immediately, the way
// RelayAuthenticator does. The socket must be fully
// wired by the time any server frame is delivered,
// so this send must be accepted — a `false` here is
// a silently lost AUTH and a dead NIP-42 handshake.
replyAccepted.trySend(socket?.send("""["CLOSE","probe"]""") == true)
}
override fun onClosed(
code: Int,
reason: String,
) {
}
override fun onFailure(
t: Throwable,
code: Int?,
response: String?,
) {
}
}
val s = InProcessWebSocket(server, listener)
socket = s
try {
s.connect()
val accepted = withTimeout(5_000) { replyAccepted.receive() }
assertTrue(
accepted,
"a reply sent from the first onMessage must reach the server, not be dropped",
)
} finally {
s.disconnect()
server.close()
}
}
}
}
@@ -0,0 +1,305 @@
/*
* 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.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* These records get published under a monitor's own key, so what matters is what
* the observer is willing to CLAIM: an unmeasured latency must never be reported
* as a measurement, a relay nobody dialled must never be reported at all, and one
* bad minute must not bury a relay that works.
*/
class RelayObserverTest {
private val url = RelayUrlNormalizer.normalize("wss://relay.example")
private val other = RelayUrlNormalizer.normalize("wss://other.example")
private class FakeRelayClient(
override val url: NormalizedRelayUrl,
) : IRelayClient {
override fun connect() = Unit
override fun needsToReconnect() = false
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit
override fun isConnected() = true
override fun sendOrConnectAndSync(cmd: Command) = Unit
override fun sendIfConnected(cmd: Command) = Unit
override fun disconnect() = Unit
}
private fun client(u: NormalizedRelayUrl) = FakeRelayClient(u)
private fun RelayObserver.only() = collectUnreported().single()
// ---- what we measured ---------------------------------------------------
@Test
fun `an opened connection is timed rather than assumed`() {
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
val obs = o.only()
assertTrue(obs.reachable)
assertNotNull(obs.rttOpenMs, "rtt-open must be measured — aggregators rank on it")
assertNull(obs.error)
}
@Test
fun `the read clock runs from the first REQ to the first EOSE`() {
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
o.onSent(client(url), "", ReqCmd("sub", emptyList()), true)
o.onIncomingMessage(client(url), "", EoseMessage("sub"))
assertNotNull(o.only().rttReadMs)
}
@Test
fun `the write clock runs from the first EVENT to its OK`() {
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
o.onIncomingMessage(client(url), "", OkMessage("id", true, ""))
assertNull(o.collectUnreported().single().rttWriteMs, "an OK with nothing sent behind it times nothing")
}
@Test
fun `a non-REQ command does not start the read clock`() {
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
o.onSent(client(url), "", CloseCmd("sub"), true)
o.onIncomingMessage(client(url), "", EoseMessage("sub"))
assertNull(o.only().rttReadMs)
}
// ---- what we refuse to claim --------------------------------------------
@Test
fun `a connection that never opened records the reason and no latency`() {
val o = RelayObserver()
o.onConnecting(client(url))
o.onCannotConnect(client(url), "Expected HTTP 101 response but was '503 Service Unavailable'")
val obs = o.only()
assertFalse(obs.reachable)
assertNull(obs.rttOpenMs, "nothing opened, so there is nothing to time")
assertTrue(obs.error!!.contains("503"))
}
@Test
fun `a relay that answered stays answered through a later failure`() {
// A relay that worked a minute ago and blipped now is not the same thing
// as one that never answered, and only the writer decides which record
// that becomes. A single failure must not erase the success under it.
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
o.onCannotConnect(client(url), "connection reset")
assertTrue(o.only().reachable, "one bad minute must not bury a relay that answered")
}
@Test
fun `a reconnect clears the previous attempt's error`() {
val o = RelayObserver()
o.onConnecting(client(url))
o.onCannotConnect(client(url), "timeout")
o.onConnecting(client(url))
assertNull(o.only().error, "a stale error would report a live relay as broken forever")
}
// ---- AUTH, which is why an anonymous crawl finds a relay empty ------------
@Test
fun `a demand for AUTH is recorded from either shape`() {
val challenged = RelayObserver()
challenged.onIncomingMessage(client(url), "", AuthMessage("challenge"))
assertTrue(challenged.only().authRequired)
val closed = RelayObserver()
closed.onIncomingMessage(client(url), "", ClosedMessage("sub", "auth-required: subscribers only"))
val obs = closed.only()
assertTrue(obs.authRequired)
assertEquals("auth-required", obs.closedReason)
}
@Test
fun `a CLOSED that is not about auth is categorised rather than misread`() {
val o = RelayObserver()
o.onIncomingMessage(client(url), "", ClosedMessage("sub", "rate-limited: slow down"))
val obs = o.only()
assertEquals("rate-limited", obs.closedReason)
assertFalse(obs.authRequired, "only an auth refusal means auth is required")
}
// ---- publishing bookkeeping ---------------------------------------------
@Test
fun `an unchanged relay is not re-reported but its measurement survives`() {
// Re-writing a record refreshes its freshness window, so a relay nobody
// re-measured must be left out. But the measurement itself has to stay:
// a long-lived socket fires onConnected once, and if publishing erased
// it, the relays we know best would be the ones we could never describe
// again.
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
val first = o.collectUnreported().single()
assertNotNull(first.rttOpenMs)
assertEquals(0, o.collectUnreported().size, "nothing new to say")
o.onIncomingMessage(client(url), "", NoticeMessage("slow down"))
val second = o.collectUnreported().single()
assertEquals(first.rttOpenMs, second.rttOpenMs, "the last real measurement still stands")
}
// ---- findings from outside the websocket client ------------------------
@Test
fun `a probe failure is published even though nothing was dialled`() {
// The cheap checks that decide NOT to open a websocket are exactly the
// ones that learn a relay is gone. Without a way in, a listener-only
// observer reports on the small minority it happened to connect to —
// 104 records out of a 16,507-relay list — which is not a census.
val o = RelayObserver()
o.record(url, reachable = false, error = "nodename nor servname provided")
val obs = o.only()
assertFalse(obs.reachable)
assertEquals("nodename nor servname provided", obs.error)
assertNull(obs.rttOpenMs, "a failed probe times nothing")
}
@Test
fun `a probe that connected reports its measured time or none at all`() {
val timed = RelayObserver()
timed.record(url, reachable = true, rttOpenMs = 42)
assertEquals(42L, timed.only().rttOpenMs)
val untimed = RelayObserver()
untimed.record(url, reachable = true)
val obs = untimed.only()
assertTrue(obs.reachable)
assertNull(obs.rttOpenMs, "reachable without a timing must not invent one")
}
@Test
fun `a failed probe does not demote a relay that already answered`() {
// Same rule the connection path follows: one bad probe is not death, and
// only the writer decides what record a mixed history becomes.
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
o.record(url, reachable = false, error = "connect timeout")
assertTrue(o.only().reachable, "it answered; a later probe failure does not erase that")
}
@Test
fun `an out-of-band finding is reported once like any other`() {
val o = RelayObserver()
o.record(url, reachable = false, error = "refused")
assertEquals(1, o.collectUnreported().size)
assertEquals(0, o.collectUnreported().size, "nothing new to say")
}
@Test
fun `each relay is observed on its own`() {
val o = RelayObserver()
o.onConnecting(client(url))
o.onConnected(client(url), 1, true)
o.onConnecting(client(other))
o.onCannotConnect(client(other), "nodename nor servname provided")
val byUrl = o.collectUnreported().associateBy { it.url }
assertTrue(byUrl.getValue(url).reachable)
assertFalse(byUrl.getValue(other).reachable)
}
// ---- the run-level summary (what RelayDiagnostics used to give) -----------
@Test
fun `the summary tallies feedback across every relay`() {
val o = RelayObserver()
assertFalse(o.hadFeedback())
o.onIncomingMessage(client(url), "", AuthMessage("c1"))
o.onIncomingMessage(client(other), "", AuthMessage("c2"))
o.onIncomingMessage(client(url), "", ClosedMessage("s", "rate-limited: slow"))
o.onIncomingMessage(client(other), "", ClosedMessage("s", "rate-limited: slow"))
o.onIncomingMessage(client(url), "", NoticeMessage("too many REQs"))
assertTrue(o.hadFeedback())
val s = o.summary()
assertEquals(2L, s["auth_challenges"])
assertEquals(2, s["auth_required_relays"])
assertEquals(mapOf("rate-limited" to 2L), s["closed_by_reason"])
assertEquals(1L, s["notices"])
}
@Test
fun `the summary outlives publishing`() {
// It answers "how did this run go", which must not be reset by the
// unrelated act of writing records out.
val o = RelayObserver()
o.onIncomingMessage(client(url), "", AuthMessage("c"))
o.collectUnreported()
assertTrue(o.hadFeedback(), "a flush must not erase the run's tally")
assertEquals(1L, o.summary()["auth_challenges"])
}
@Test
fun `a machine-readable prefix is extracted or falls back to other`() {
assertEquals("auth-required", RelayObserver.prefixOf("auth-required: come back signed"))
assertEquals("other", RelayObserver.prefixOf("just some prose"))
assertEquals("other", RelayObserver.prefixOf(""))
}
}
@@ -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.quartz.utils
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class HexExactSizeTest {
val id64 = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf"
val sig128 = id64 + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
@Test
fun decode64RoundTrip() {
assertEquals(id64, Hex.encode64(Hex.decode64(id64)))
assertContentEquals(Hex.decode(id64), Hex.decode64(id64))
assertContentEquals(Hex.decode(id64), Hex.decode64OrNull(id64))
}
@Test
fun decode64AcceptsUpperCase() {
assertContentEquals(Hex.decode(id64), Hex.decode64(id64.uppercase()))
}
@Test
fun decode64RejectsWrongLengths() {
assertFailsWith<IllegalArgumentException> { Hex.decode64("") }
assertFailsWith<IllegalArgumentException> { Hex.decode64(id64.drop(1)) }
assertFailsWith<IllegalArgumentException> { Hex.decode64(id64.drop(2)) }
assertFailsWith<IllegalArgumentException> { Hex.decode64(id64 + "ab") }
assertFailsWith<IllegalArgumentException> { Hex.decode64(sig128) }
assertNull(Hex.decode64OrNull(""))
assertNull(Hex.decode64OrNull(id64.drop(2)))
assertNull(Hex.decode64OrNull(id64 + "ab"))
assertNull(Hex.decode64OrNull(sig128))
}
@Test
fun decode64RejectsInvalidChars() {
// every position, both a plain non-hex char and an emoji (code > 0xFF)
for (i in 0 until 64) {
val withG = id64.substring(0, i) + "g" + id64.substring(i + 1)
assertNull(Hex.decode64OrNull(withG), withG)
assertFailsWith<IllegalArgumentException> { Hex.decode64(withG) }
}
val withEmoji = "🥰" + id64.drop(2)
assertNull(Hex.decode64OrNull(withEmoji))
assertFailsWith<IllegalArgumentException> { Hex.decode64(withEmoji) }
}
@Test
fun decode128RoundTrip() {
assertEquals(sig128, Hex.encode128(Hex.decode128(sig128)))
assertContentEquals(Hex.decode(sig128), Hex.decode128(sig128))
assertContentEquals(Hex.decode(sig128), Hex.decode128OrNull(sig128.uppercase()))
}
@Test
fun decode128RejectsWrongLengthsAndInvalidChars() {
assertFailsWith<IllegalArgumentException> { Hex.decode128("") }
assertFailsWith<IllegalArgumentException> { Hex.decode128(id64) }
assertFailsWith<IllegalArgumentException> { Hex.decode128(sig128.drop(2)) }
assertFailsWith<IllegalArgumentException> { Hex.decode128(sig128 + "ab") }
assertNull(Hex.decode128OrNull(id64))
assertNull(Hex.decode128OrNull(sig128.dropLast(1) + "x"))
assertNull(Hex.decode128OrNull("🥰" + sig128.drop(2)))
}
@Test
fun encodeRejectsWrongSizes() {
assertFailsWith<IllegalArgumentException> { Hex.encode64(ByteArray(31)) }
assertFailsWith<IllegalArgumentException> { Hex.encode64(ByteArray(33)) }
assertFailsWith<IllegalArgumentException> { Hex.encode64(ByteArray(64)) }
assertFailsWith<IllegalArgumentException> { Hex.encode128(ByteArray(32)) }
assertFailsWith<IllegalArgumentException> { Hex.encode128(ByteArray(63)) }
assertFailsWith<IllegalArgumentException> { Hex.encode128(ByteArray(65)) }
}
@Test
fun randomsMatchGenericDecode() {
for (i in 0..1000) {
val id = RandomInstance.bytes(32)
assertEquals(Hex.encode(id), Hex.encode64(id))
assertContentEquals(id, Hex.decode64(Hex.encode64(id)))
val sig = RandomInstance.bytes(64)
assertEquals(Hex.encode(sig), Hex.encode128(sig))
assertContentEquals(sig, Hex.decode128(Hex.encode128(sig)))
}
}
}