Commit Graph
1218 Commits
Author SHA1 Message Date
Claude 922a5841d0 fix(nip46): make forgetting a client complete and immediate
Clearing a connected client on logout had two gaps:

- The user-facing "Forget this app" button only revoked the permission ledger;
  it never cleared the NIP-46 client store, so a forgotten app's metadata and
  relays lingered and were re-recovered on the next restart. Route NIP-46
  coordinates through the host's new forgetClient() so the store is cleared too.
- Neither logout path stopped the RUNNING session from listening on the app's
  relays — only the next restart picked up the change. extraRelays is now a live
  projection of the client store (recomputed on connect, on start, and on
  disconnect via a new onDisconnected hook), so a forgotten app's relays are
  dropped immediately.

onLogout and the UI Forget now share one authorizer.forget() path (revoke grant
+ clear store + clear throttle entry + signal the host), so client-initiated and
user-initiated disconnects behave identically. Adds tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude cd3e1353e7 docs(nip46): correct stale coordinate format in KDoc
Two doc comments still described the pre-namespacing coordinate
`nip46:<clientPubKey>`; the actual key is `nip46:<signerPubKey>:<clientPubKey>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude c20cc2b514 refactor: move the generic signer-permission layer out of napplet/
The per-app signing-authorization plumbing was named/located under `napplet/`
for historical reasons, but it is not napplet-specific — it already gates
napplets, the sandboxed browser, and (now) NIP-46 remote clients through one
shared ledger. The package name mislabelled what the code is, so:

- commons: `napplet/signers/` (generic) → `connectedApps/signers/`
  (AppSignerPolicy, NostrOpDecision, NostrSignerOp, NostrSignerConsentPrompt,
  NostrSignerPermissionLedger/Store). The NIP-46-specific bridge moves to
  `connectedApps/nip46/` (Nip46PermissionAuthorizer, Nip46ClientStore), so the
  feature is no longer split across unrelated packages.
- The `NappletRequest.toSignerOp()` extension — napplet protocol leaking into
  the generic layer — moves back to `napplet/protocol/`.
- amethyst: `napplet/DataStoreNostrSignerPermissionStore` → `connectedApps/`,
  `napplet/DataStoreNip46ClientStore` → `connectedApps/nip46/`.

Pure move + repackage: all 27 import sites updated, no behaviour change.
Napplet-specific code (broker, capabilities, consent, :nappletHost) and the
Connected Apps UI folder are untouched — those really are napplet/UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude d06b1c7f2c feat(nip46): rate-limit flooding clients + clean up on logout
Follow-ups to the audit:

- Abuse protection: the signer service now bounds its event queue
  (DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
  be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
  can no longer force one per event or grow the queue without limit. Fixed
  window (default 40 requests / 10s per author, oldest authors evicted). The
  limiter is touched only by the single consumer coroutine, so it needs no
  locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
  ledger grant), so a disconnected app stops being listened for after restart.

Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude fe4e881df6 fix(nip46): audit fixes — data race, cancellation, write amplification
Findings from an audit of the signer, all verified against the code:

- Data race: NostrConnectSignerService deduped request ids inside onEvent,
  which the relay pool invokes CONCURRENTLY from each relay's socket thread
  (PoolRequests dispatches listeners outside its lock). Two relays delivering
  the same subscription could mutate the LinkedHashSet at once → race / CME.
  Move dedup into the single consumer coroutine; onEvent now only does the
  thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
  processor, the service's decrypt + publish, and connectViaNostrConnect caught
  CancellationException too, breaking structured cancellation when the service
  restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
  file on EVERY authorized request (unthrottled, unlike the relay-auth store).
  Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
  so a duplicate inbox-relay emission tore the subscription down and re-opened
  it on every relay for nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude 9667168a6a feat: persist connected NIP-46 client metadata + relays
Add a Nip46ClientStore (commons interface + InMemory + a single-file Android
DataStore) keyed by the same signer-namespaced coordinate as the permission
ledger, holding each connected client's self-declared name/url/image and the
relays it reaches us on.

- The host persists metadata on connect (bunker + nostrconnect) and, for the
  nostrconnect flow, the app's own relays. On startup it re-adds those relays
  to the listen set, so a nostrconnect-paired app stays reachable across app
  restarts instead of silently going dark until it re-pairs.
- Connected Apps now shows the app's real name (falling back to the generic
  label + npub) for remote-signer clients.
- Wired the store through AppModules → AccountCacheState → Account → host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude dea711596e feat: namespace NIP-46 grants by signer + revoke on logout
The Connected Apps signer store is app-global, so a remote client keyed only by
its own pubkey would share one trust level across every local account. Namespace
the coordinate as `nip46:<signerPubKey>:<clientPubKey>` so the same client paired
with two accounts on one device gets independent grants.

- Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo
  encode + match the namespace; clientPubKeyOf reads the trailing segment.
- onLogout now revokes the client's grant (wired through the new quartz hook).
- Connected Apps lists only the active account's remote clients (napplet/browser
  grants stay app-global); the signer screen counts the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude 81aea57ddb feat(commons): ledger-backed NIP-46 authorizer for Connected Apps
Nip46PermissionAuthorizer implements the quartz Nip46RequestAuthorizer by
routing every remote-signer request through the shared Connected Apps
permission ledger (NostrSignerPermissionLedger). A NIP-46 client becomes a
connected app under the coordinate `nip46:<clientPubKey>`, so it reuses the
same per-app trust levels and per-op overrides as napplets and web origins:

- sign/encrypt/decrypt requests map to NostrSignerOp and are allowed only when
  the ledger's standing decision is ALLOW (ASK/DENY are refused — a background
  signer cannot prompt, so access is granted ahead of time in the UI).
- connect validates the pairing secret, then registers the app at a default
  REASONABLE policy (never downgrading a level the user already set) and echoes
  the secret back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
Claude e2743ed0b6 fix: address pre-merge audit findings for geohash chat
Correctness:
- GeoRelayDirectory.relays is now @Volatile; on the process-wide `shared`
  directory the CSV refresh was written by one thread and read by others with
  no memory barrier, so readers could keep using the FALLBACK list forever and
  never route to the correct rendezvous relays.
- sendPostSync bails before cancel() when a geohash cell has no resolvable
  relays, so the composer text + draft are preserved instead of the message
  being silently dropped with its draft deleted.
- Teleport detection compares on the common geohash prefix; a cell finer than
  the fixed 8-char device fix could never be a startsWith prefix, so the user
  was wrongly marked teleported even when physically present.

Performance:
- GeoRelayDirectory.closest precomputes each relay's great-circle distance
  once instead of recomputing the trig inside the sort comparator (was
  O(n log n) haversine calls over the ~370-relay directory).
- GeohashChatChannel.relays() memoizes the derived set, invalidated by a new
  directory version token, instead of re-sorting the whole directory (and
  allocating a fresh Set) on every call.
- filterFollowingGeohashChats groups cells by relay into one filter each
  (g = [cells]) rather than one REQ per (cell, relay).

Leak/thread-safety:
- FollowingGeohashChatSubAssembler.userJobMap is a ConcurrentHashMap and
  endSub now removes the entry (it previously cancelled the jobs but left the
  stale entry behind).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
2026-07-17 02:10:18 +00:00
Claude 4c9f599a79 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-17 00:24:33 +00:00
Vitor PamplonaandGitHub c2b2be322d Merge branch 'main' into claude/amethyst-mobile-colors-kgdfhn 2026-07-16 20:15:27 -04:00
Claude 22a3bb31d5 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-17 00:10:58 +00:00
Claude 420fdfea53 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-07-16 23:52:28 +00:00
Claude 8891e3a1a7 feat: match the Following badge to the FilledTonalButton palette
Per review, the follow badge now uses the same two colours as the tonal
buttons (Show more, profile actions): the shield is secondaryContainer and the
inner figure is onSecondaryContainer. The vector is a following(shield, figure)
builder cached per colour at the call site, so the badge follows the accent and
stays visually consistent with the tonal buttons in both themes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
2026-07-16 23:33:01 +00:00
Claude dd03307ac3 fix: solid accent container tones + keep Following its brand purple
Two more on-device fixes:

- Container roles were a faint 0.12/0.16 tint over the surface, so filled
  shapes like the Settings icon boxes nearly vanished (accent icon on almost no
  background) and tonal buttons looked washed. Retune the container tones to a
  moderately saturated fill (mirroring Material's baseline containers, recoloured
  to the accent) with near-white content in dark mode, so the settings icon
  boxes and profile-header tonal buttons read as white-on-accent again.

- The Following badge keeps its original deep purple (#7F2EFF) instead of
  following the accent — it reads as a brand identity mark, and the accent-tinted
  version looked flat. Reverted to the fixed two-tone vector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
2026-07-16 22:59:40 +00:00
Claude 853d5e1b25 fix: address accent regressions — teal tonal buttons, FAB glyph, follow badge
Three fixes from on-device review:

- Secondary/tertiary CONTAINER roles now derive from the accent (primary)
  instead of the purple theme's teal secondary. FilledTonalButton (profile
  follow/edit/message, "Show more"/"Show anyway", tonal chips) was neutral
  before and had turned teal-on-teal; it now reads as an accent tonal button.
  The solid secondary/tertiary roles stay teal (unchanged, as before).

- onPrimary is white again. Deriving it by max contrast made it black on the
  light accents used in dark mode, flipping FAB glyphs from white to black;
  white reads better on the accent.

- The Following badge is two-tone again: the shield follows the accent while
  the inner figure stays white, instead of a flat single-colour shield. The
  vector is now a following(accent) builder cached per accent at the call site.

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

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

Verified: :commons JVM and :desktopApp compile.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 21:56:12 +00:00
Claude 2725a93569 fix: keep the liked heart and reposted check their semantic colours
The previous change made every commons action icon follow the accent, but the
liked heart (red) and reposted check (green) are semantic status colours, not
brand purple. Restore their original baked colours and Color.Unspecified
rendering. Only the Following badge — which was brand purple — keeps following
the accent.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 21:43:56 +00:00
Claude 479c20a2e6 refactor: make commons action icons tintable and follow the accent
The Following, Liked and Reposted vectors baked their own colours (a purple
shield, a red heart, a green check) and were rendered with Color.Unspecified,
so they ignored the theme and stayed those fixed hues under every accent.

Strip the baked colours to a neutral tintable black and tint them at the icon
wrappers with the theme primary, so the follow badge, liked heart and reposted
check now follow the user's selected accent. Call sites that relied on the old
baked colour via Color.Unspecified are pointed at the accent (or keep their
explicit tint, e.g. white-on-coloured-background). The already-monochrome
vectors (Bookmark, Like, Reply, Repost, Search, Share, Zap, ZapSplit) were
already tinted by their callers and are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
2026-07-16 21:36:05 +00:00
Claude 435ad29f11 feat(commons): share hashtag icons + emoji; route Android RichTextViewer through the shared core
Closes the fidelity gap and flips Amethyst's rich text onto the shared core:
- Move the hashtag-icon table (HashtagIcon + checkForHashtagWithIcon) into
  commons/ui/richtext (the icons were already in commons); amethyst re-exports
  it for existing call sites.
- Add a commons custom-emoji renderer (RenderCustomEmoji + InLineIconRenderer)
  built on quartz CustomEmoji.assembleAnnotatedList, mirroring CreateTextWithEmoji.
- Wire both into the commons RichTextViewer core (hashtags now show shared inline
  icons; emoji render inline).
- Point amethyst's RichTextViewer at CommonsBackedRichTextViewer (the shared core
  is now the production plain-text path; ~55 call sites unchanged) and delete the
  now-dead private RenderRegular switchboard. Markdown stays native.

Verified: :commons JVM and :amethyst play debug compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 21:30:54 +00:00
Claude c8deb0f227 feat(commons): prototype shared rich-text rendering contract
Introduces commons/ui/richtext: one cross-platform RichTextViewer that
both the touch (Amethyst Android) and mouse-first (Desktop) front ends
can drive, to replace the two current forks (amethyst RichTextViewer +
DesktopRichTextViewer).

The shared core owns the universal parts (paragraph/RTL/word layout,
plain text, inline custom emoji, hashtags) and delegates the segments
whose *presentation and* call-to-action diverge by platform (media,
equation, quoted event, mention, payment, link preview, relay/invite,
secret message) to a RichTextSegmentRenderer strategy provided via
LocalRichTextSegmentRenderer -- the same CompositionLocal idiom the
codebase already uses for LocalInlineQuoteRenderer. Universal actions
(open url/email/phone, hashtag) go through a small RichTextInteractions
callback bag. A PlainTextSegmentRenderer default keeps the core usable
from previews/tests/headless callers.

Contract + skeleton only; compiles in :commons. No consumer wired yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 20:45:58 +00:00
Claude a068eaf721 docs(commons): add rich-text restructure as gating Phase 0
TranslatableRichTextViewer can't move (ML-Kit translation is Android),
but the stack is already layered: translation wraps ExpandableRichText
wraps the 1031-LOC RichTextViewer core. TranslationConfig state is
already in commons and the core's real AccountViewModel surface is 5
members (3 cache reads -> ICacheProvider, toast -> callback, nav ->
callbacks). Move the core to commons/ui/text behind the cache port +
callbacks with a renderEmbeddedNote slot for the NoteCompose recursion;
keep the translation wrapper native and thin. This gates Tier 2 so
nested rich text becomes a direct commons call, not a per-renderer slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 18:49:57 +00:00
Claude 0f07d63d7a docs(commons): read note lookups through the cache port, not lambdas
Account already injects `val cache: LocalCache` and LocalCache implements
the commons `ICacheProvider`/`ILocalCache` read ports, but AccountViewModel
lookups bypass account.cache and hit the object singleton directly, and
IAccount doesn't expose the cache. Revise the extraction seam: reads cross
via `IAccount.cache: ICacheProvider` (add it) rather than app-supplied
loader lambdas; reserve lambdas for nav + write/signer actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 18:47:31 +00:00
Claude 654dd2b1b3 docs(commons): review plan for extracting ui.note rendering to commons
Study of com.vitorpamplona.amethyst.ui.note (214 files / ~48.7k LOC,
types/ = 89 files) for moving the rendering half into commons via a
Render (entry, stays native) -> Display (pure, moves to commons) split.
Documents the canonical entry signature, the AccountViewModel/INav/
R.string/leaf-toolkit dependency surface, the @Composable-slot seam for
the flavor-specific rich-text viewer, a tiered categorization of the 89
type renderers, and a per-event sequencing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 18:39:05 +00:00
Vitor PamplonaandGitHub 829994076f Merge pull request #3596 from vitorpamplona/claude/nip-29-compliance-review-ngzzaw
NIP-29: subgroups, custom roles, timeline refs, invite links + relay-signed hardening
2026-07-16 09:37:28 -04:00
nrobi144andClaude Opus 4.8 d1ed9d071b fix(desktop): auto-approve pending AUTH once the DM-inbox relay set loads
On cold boot an AUTH-required kind:10050 DM-inbox relay usually sends its
AUTH challenge before the account's own kind:10050 list has been fetched.
AuthApprovalPolicy.classify reads the trusted (self-approved) relay set
exactly once, so it classifies the user's OWN inbox relay as tier-2 and
surfaces a manual `[Once][Always][Never]` banner. Nothing re-evaluates
that pending decision when the kind:10050 list finally loads, so the user
gets a spurious AUTH prompt for a relay that should have auto-signed.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:31:18 +03:00
Claude 0e8239b22f fix(nip29): audit fixes — subgroup edit safety, subscription load, roles
Bugs:
- Metadata edit could re-root a subgroup or drop its children on a load race:
  the edit ViewModel snapshotted parent/children at prefill and overrode the
  Account-level live-read defaults. Children are no longer snapshotted (Account
  reads the live child list at save time), and the parent is only overridden
  when the user actually re-parents (parentTouched) — a plain rename can't
  re-root or orphan children anymore, even if metadata hadn't loaded yet.
- Parent selector card cached a null channel via remember() and never
  refreshed, so the parent's name/picture never loaded and the warm-up never
  mounted. Now get-or-create + warm + observe the metadata flow.
- previousEventRefs could let a note with an unresolved author slip past the
  self-exclusion and reference the sender's own event. Now requires a resolved
  author.
- Assigning a relay-defined role replaced the member's whole role set while the
  menu implied additive; now keeps existing roles (entry.roles + role.name).
- GroupNAddrInvite now also accepts a bare `invite=<code>` remainder if the `?`
  is stripped upstream (+ test).

Performance:
- Subgroups bar mounted a full warm-up (metadata + content) subscription per
  child chip — up to ~21 relay subscriptions per open group. Replaced with one
  relay-directory subscription; chips read from cache.
- Parent picker recomputed the whole candidate scan on every recomposition
  (each search keystroke) via a produceState initial-value argument; the scan
  now lives only in the producer with a cheap empty initial.

spotless clean; quartz tests green; amethyst compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
2026-07-16 04:19:25 +00:00
Claude c236474f88 feat(nip29): timeline refs, custom roles, subgroup nav, naddr invites
Follows up the subgroup protocol work with four NIP-29 compliance/UX gaps.

previous timeline references (spec §Timeline references):
- RelayGroupChannel.previousEventRefs(): first-8-char id prefixes of the most
  recent events seen from the host relay, excluding the sender's own, capped at
  the spec's 50-event window. Only draws from events actually received in the
  channel so the host relay is known to have them.
- Populate the `previous` tag on all outgoing group events: kind-9 chat and
  replies, kind-1111 minichat comments, kind-11 threads, and group replies.

Custom roles (kind 39003):
- Route SupportedRolesEvent onto the channel (LocalCache.consume + a
  RelayGroupChannel.supportedRoles field) instead of only storing it.
- Members screen: when the relay advertises a role set, offer those roles when
  assigning (admins), and show each member's real relay-assigned role label
  instead of collapsing everything to admin/moderator. Falls back to the
  built-in admin/moderator shortcuts when no 39003 is published.

Subgroup navigation (spec §Subgroups):
- RelayGroupSubgroupsBar: a self-hiding bar under the pinned bar showing a
  breadcrumb up to the parent group and chips for the child subgroups (in the
  relay's `child` order), each opening that group on the same host relay.

naddr invite codes (spec §Group identifier):
- GroupNAddrInvite parses the `naddr1…?invite=<code>` suffix; both the tap
  handler (ClickableRoute) and the deep-link handler (MainActivity) now feed it
  into the kind-9021 join request so a shared invite naddr auto-joins.

Tests for the naddr invite parser. spotless clean; quartz tests green;
amethyst compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
2026-07-16 02:29:10 +00:00
Vitor PamplonaandGitHub 99f28ee1ca Merge pull request #3593 from vitorpamplona/claude/nip-51-compliance-review-vwhyvn
Add hashtag muting support via NIP-51
2026-07-15 22:07:00 -04:00
Claude 956385843e refactor(geohash-chat): load room via shared channel feed + datasource
The in-room geohash chat now uses the same data path as every other chat
(public/ephemeral/live/relay-group) instead of a hand-rolled subscription:

- GeohashChatChannel.relays() resolves the cell's geographically-nearest
  relays from a process-wide GeoRelayDirectory.shared (populated by
  GeohashRelays.ensureLoaded), so the subscription layer can reach a cell
  before its first message arrives.
- filterMessagesToGeohashChat wires kind-20000 into ChannelPublicFilterSubAssembler,
  so ChannelFilterAssembler assembles the geohash subscription like any channel.
  Own messages carry the same g tag, so no separate from-user filter is needed.
- GeohashChatScreen loads the feed through ChannelFeedViewModel (LocalCache-backed,
  with mute-filtering for free) + ChannelFilterAssemblerSubscription, mirroring
  LoadEphemeralChatChannel/EphemeralChatChannelView. GeohashChatViewModel drops its
  bespoke client.subscribe and keeps only the geohash-specific composer bits
  (relay resolution for sending, anonymous identity, teleport / post-as-self, PoW).

Rendering stays custom so bitchat nicknames (n tag), the teleport marker, and
anonymous own-message alignment survive — the profile-based shared renderer
can't express those for throwaway per-cell identities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
2026-07-16 01:58:45 +00:00
Claude abc6732286 feat: support NIP-51 mute-list hashtag ("t") entries
NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys),
`word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first
three, so `t` hashtag mutes written by other clients were silently
dropped: uncounted, invisible, and never applied to filtering.

Quartz:
- Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and
  register it in MuteTag.parse/isTagged so it round-trips like the other
  entry types.
- Add mutedHashtags()/mutedHashtagIds() TagArray helpers.

Filtering (commons):
- Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide
  notes carrying a muted hashtag in Note.isHiddenFor() (exact, case-
  insensitive `t`-tag match — distinct from the existing substring word
  scan).

Amethyst:
- Aggregate HashtagTag entries from the mute/block lists in
  HiddenUsersState.
- MuteListState.hideHashtag/showHashtag + Account and AccountViewModel
  wrappers, and observeUserIsMutingHashtag.
- Surface a Mute/Unmute hashtag action in the hashtag screen's options
  overflow menu.

Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and
NoteIsHiddenForTest cases for muted-hashtag hiding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
2026-07-16 01:51:48 +00:00
Claude e24eec9ca6 feat(nip29): subgroup hierarchy (parent/child) for relay groups
Implements the NIP-29 Subgroups feature merged upstream: groups can now be
organized into a parent/child tree, scoped per host relay.

Quartz:
- Add `parent`/`child` tag classes and TagArray (builder) helpers.
- GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and
  build params.
- EditMetadataEvent (9002): parent()/children() accessors and build params
  (a 9002 re-carries the full child list, per spec, or the relay rejects it).
- SubgroupTree: assembles a relay's flat 39000 set into the hierarchy —
  structure follows each group's parent tag, sibling order follows the
  parent's child-tag order, orphans surface as roots, and malformed cycles
  are broken rather than looping.
- NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`,
  with a `subgroups()` builder DSL helper.
- Tests for tag round-trips, tree assembly, ordering, orphans and cycles,
  plus NIP-11 serialization.

Amethyst:
- RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the
  latest metadata.
- Account.editRelayGroupMetadata: preserve the group's current parent and full
  children list on a plain metadata edit so an admin renaming a subgroup no
  longer detaches it (or gets rejected for dropping children).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
2026-07-16 01:51:40 +00:00
Vitor PamplonaandGitHub 67efac7972 Merge pull request #3592 from vitorpamplona/claude/amethyst-invite-link-hang-w0tagl
CORD-05: Distinguish invite redemption failures for better UX
2026-07-15 21:29:52 -04:00
Claude ae9f4a0def fix(concord): resolve invite coordinate per CORD-05 (honor revocation)
Follow-up making the invite redeemer match the CORD-05 §2 spec for the
addressable invite coordinate (33301, link_signer, d=""):

- vsk=6 → live bundle (open with the link token)
- vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries
  even a stale, still-openable copy on another relay ("a fetcher finds the
  grave instead of keys"). Amethyst previously never checked for this, so a
  revoked link failed generically.
- anything else present (e.g. a mis-posted registry vsk=8, the shape of the
  relayop.xyz link that hung) → unreadable
- nothing on any relay → absent

New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in
quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`,
and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult`
cases — including a new `Revoked` outcome with its own message and no futile
retry. Crypto is unchanged and already matches the spec
(hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite).

Adds ConcordInviteClassifyTest covering live / revoked (order-independent) /
unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
2026-07-16 01:24:37 +00:00
Claude 6735c0b7a6 fix(commons): import kotlinx.coroutines.IO for native Dispatchers.IO access
`BlossomServerListState` used `Dispatchers.IO` without importing the
common `kotlinx.coroutines.IO` accessor. On JVM/Android the JVM member
resolved fine, but on Kotlin/Native (iosSimulatorArm64) it resolved to
the internal `Dispatchers.IO` member and failed to compile:

    Cannot access 'val IO: CoroutineDispatcher': it is internal in
    'kotlinx.coroutines.Dispatchers'.

Add the missing `import kotlinx.coroutines.IO`, matching every other
common-source state holder in this module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtPgFEy9AxoaUT5AqEFBZX
2026-07-16 01:10:31 +00:00
Claude 6657957677 feat(geohash-chat): native Messages rooms via LocalCache (Phase A+B)
Makes geohash location channels first-class in the Messages tab by routing them
through the same LocalCache -> feed machinery every other room uses:

Phase A (foundation):
- commons GeohashChatChannel : Channel, keyed by the bare geohash, with a
  placeholder-note so a just-joined cell shows before its first message.
- LocalCache: geohashChannels map, get/getOrCreateGeohashChannel, a
  consume(GeohashChatEvent) that routes kind-20000 messages into the cell's
  channel (presence 20001 stays with the live screen), plus getAnyChannel + the
  prune loops.
- GeohashRelays: a process-wide geohash->relay directory (live CSV once, fallback
  otherwise). FollowingGeohashChatSubAssembler + filterFollowingGeohashChats
  subscribe the joined cells (account.geohashList) to each cell's nearest relays,
  registered in ChatroomListFilterAssembler.

Phase B (Messages):
- ChatroomListKnownFeedFilter: a geohashChannels family (feed + incremental
  updateListWith/applyFilter + filterRelevantGeohashChats + geohashRowKey so a
  placeholder and its later real message resolve to one row).
- ChatroomHeaderCompose: a GeohashRoomCompose row (location pin, anonymous —
  name from the message's n tag) -> Route.GeohashChat.
- AccountFeedContentStates: rebuild the list when the joined set changes.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
2026-07-16 00:15:28 +00:00
Vitor PamplonaandGitHub c95270fca3 Merge pull request #3583 from vitorpamplona/claude/blossom-server-loading-sync-n1l3sw
Sync Blossom media server list (kind 10063) across clients
2026-07-15 20:11:48 -04:00
Vitor PamplonaandGitHub 13cbcf41d6 Merge pull request #3586 from vitorpamplona/claude/nip29-message-pinning-elzck9
NIP-29 message pinning: UI bar, moderation events, and relay group integration
2026-07-15 19:56:53 -04:00
Claude b8966049b8 fix: Concord channel rows stuck on "No messages yet" despite having messages
consumeConcordRumor attaches a message row to its ConcordChannel BEFORE
justConsume loads the event (so the note carries its gatherer through the
Messages filter). That means Channel.addNote ran while createdAt() was still
null and never set lastNote — and on reprojects the containsKey guard skips
addNote entirely, so lastNote stayed null forever for every Concord channel.
The list/hub rows read lastNote, so they always showed "No messages yet" (and
activity sorting was a no-op) even after messages had loaded.

Adds Channel.refreshAfterEventLoad(note), called right after justConsume once
the event is present: it picks lastNote when newer and invalidates the notes
flow so previews, unread counts, and ordering recompute. Regression-tested in
commons (ConcordChannelLastNoteTest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
2026-07-15 21:59:25 +00:00
Claude 9ee6f0dbde feat(nip29): message pinning for relay groups
Implements NIP-29 message pinning (nostr-protocol/nips#2379):

Protocol (quartz):
- GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d`
  group id + ordered `e` ids.
- UpdatePinListEvent (kind 9010): moderator `update-pin-list` write,
  carries the full list so pin/unpin/reorder/clear are one submission.
- Register both in EventFactory; add a pinnedEventIds tag helper.

Model + cache:
- RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned)
  with the same createdAt-supersede guard as the roster.
- LocalCache consumes 39005 into the channel and stores the 9010 write;
  39005 added to the group's metadata REQ filter so pins load.

Publish path:
- Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers.

UI (non-intrusive, self-hiding):
- Collapsed pinned-message bar under the top bar: shows the current pin,
  N-count cycling, tap to jump to the message in-feed (hoisted jump
  request threaded through the shared chat feed view). Renders nothing
  when the group has no pins.
- Moderator-only Pin/Unpin action under "Show more" in the chat message
  bottom drawer, gated on membership.canModerate().
- Small pin glyph on pinned bubbles' footer.

Tests: quartz build/parse round-trip for both kinds; channel pin-fold
supersede/replace/clear semantics.
2026-07-15 21:24:37 +00:00
Claude e322c939cc feat(desktop): load Blossom servers from kind 10063 like mobile
The desktop app read its Blossom media server list only from a local
DesktopPreferences string (defaulting to blossom.primal.net) and never
looked at the user's NIP-B7 BlossomServersEvent (kind 10063) — the same
event the Amethyst mobile app loads via BlossomServerListState. A server
list configured on mobile therefore never showed up on desktop.

Load the list from the network event instead, mirroring the existing
desktop NIP-65 flow:

- Add a shared, platform-agnostic BlossomServerListState in commons that
  reads the kind-10063 addressable event from ICacheProvider and exposes
  a StateFlow<List<String>> plus a save helper.
- Store incoming kind-10063 events in DesktopLocalCache.route()
  (consumeBlossomServerList, newest-per-author wins).
- Instantiate blossomServerList on DesktopIAccount and subscribe to
  kind 10063 in the account-config bootstrap subscription.
- Mirror the loaded network list into DesktopPreferences so the upload
  path and cold start reflect it; the network event stays authoritative.
- Feed the media-server settings screen from the network list and, on
  edit, sign+broadcast a new kind-10063 event so changes sync to every
  Amethyst client (writeable accounts only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
2026-07-15 20:56:57 +00:00
Claude 8abfb56440 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-15 20:38:14 +00:00
Claude 56e3ab5b8b feat(concord): stamp the wrap's seen-on relays onto the decrypted rumor
Like NIP-17's addRelayToNoteAndInners, propagate the relays a Concord plane
wrap was seen on down to its inner rumor, so a received Concord/Armada
message shows the relays it actually came from — not just the channel's
configured relay set.

The wrap note already carries its seen-on relays (added by consumeRegularEvent
before the gift-wrap handler runs), so GiftWrapEventHandler hands them to
concordSessions.ingest, which threads them through the session/registry to the
rumor sink; LocalCache.consumeConcordRumor then stamps them onto the rumor note
after justConsume. Local-echo sends and buffer re-projections pass no relays
(default empty) since there's no per-wrap relay to attribute there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
2026-07-15 20:00:47 +00:00
Claude ba93dd3564 feat(commons): geohash-relay directory for Bitchat location-channel routing
Adds GeoRelayDirectory, which maps a geohash cell to the Nostr relays closest to
its center so a client lands on the same relays every other client of that cell
uses (the rendezvous rule Bitchat location channels rely on): closest-N by
haversine distance with a host tie-break and :443 dedup, a parser for the public,
MIT-licensed georelays CSV both clients load, a small built-in fallback, and a
jvmAndroid GeoRelayCsvLoader that refreshes the live CSV over the app's OkHttp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
2026-07-15 16:17:23 +00:00