Commit Graph
16921 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 51607ff4cf Merge pull request #3566 from vitorpamplona/claude/concord-quartz-amethyst-plan-0oy779
Concord: end-to-end-encrypted communities (CORD-01…07) on Android + CLI
2026-07-14 23:14:36 -04:00
Claude b0d18b2983 fix(concord): import kotlin.jvm.JvmInline so ConcordPermissions compiles on iOS
`kotlin.jvm.*` is a default import on the JVM target but not on Kotlin/Native,
so `@JvmInline` on the ConcordPermissions value class resolved on JVM/Android
yet failed the iOS (compileKotlinIosSimulatorArm64) build with "Unresolved
reference 'JvmInline'". Add the explicit import; JVM is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-15 02:29:18 +00:00
Claude a47fd206e6 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
2026-07-15 02:12:18 +00:00
Vitor PamplonaandGitHub 22c2305e93 Merge pull request #3565 from vitorpamplona/claude/public-chat-creation-fab-p9s72t
Add floating action button to create new public chats
2026-07-14 22:00:44 -04:00
Vitor PamplonaandClaude Opus 4.8 984cafd1fb fix(concord): community title fallback + round channel-create FAB
The channel-list screen titled itself `state.metadata.name ?: app_name`, so
before the metadata edition folded it showed the app's own name — "Amy Debug"
in a debug build. Prefer the folded name, then the stored community name from
the list entry (always present from the join/create, and what shows everywhere
else); the app-name fallback is now effectively unreachable.

The channel-create FAB used Material 3's default rounded-square shape; every
other FAB in the app is a circle. Set shape = CircleShape to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:00:04 -04:00
Vitor PamplonaandClaude Opus 4.8 edbc910941 fix(concord): upload community icon/banner off the main thread
Picking a community icon or banner always failed with "Failed to upload media".
The upload was launched from the form's rememberCoroutineScope() (the Compose
Main dispatcher), so the very first pipeline step — MediaCompressor.compress —
hit Amethyst's checkNotInMainThread() guard and threw OnMainThreadException
before any bytes left the device.

Run ConcordImageUploader.uploadEncrypted inside withContext(Dispatchers.IO) so
the whole compress → strip → AES-GCM-encrypt → Blossom pipeline is off-main; the
Compose state write stays on the launching (Main) scope.

Also stop the form's catch from swallowing the real cause: surface the actual
exception message in the toast (falling back to the generic string only when it
has none), log it, and rethrow CancellationException instead of eating it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:00:04 -04:00
Vitor PamplonaandClaude Opus 4.8 5b54eb88a5 fix(concord): notify on reactions to my Concord messages
NotificationFeedFilter derived `isConcord` from the note's own gatherers
(is it in a joined ConcordChannel), but LocalCache.consumeConcordRumor only
attaches kind-9 messages and kind-1111 replies to the channel — never a kind-7
reaction. So a reaction's `isConcord` was always false, it didn't bypass the
follow filter, and since a fellow member usually isn't a follow it was dropped
in Curated/Selected mode.

Recognize a reaction/repost as Concord through its TARGET instead: if
`replyTo.lastOrNull()` is a message in a community I've joined, it bypasses the
follow filter exactly like a reply. Relevance is still the existing p-tag gate,
so only reactions that actually tag me notify (a well-formed NIP-25 kind-7
p-tags the reacted author, which is what our own ChannelChat.reaction writes).
The "Messages in notifications" toggle now gates only Concord messages, not
reactions — a like isn't a message, so it follows the same rule as any other
reaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:58:48 -04:00
Vitor PamplonaandClaude Opus 4.8 08c1d1d539 fix(concord): show the quoted parent on kind-9 chat replies in NoteCompose
A Concord (and MLS/WhiteNoise) chat reply is a kind-9 ChatEvent that carries
its reply target as a NIP-18 `q` (or NIP-10 `e`) tag, not a NIP-10 thread — so
it isn't a BaseThreadedEvent and RenderTextEvent's reply-to preview never fires
for it. On the chat feed the preview is drawn by chat-only code, but everywhere
else NoteCompose routes kind-9 through RenderChat, which rendered only the
content and never `note.replyTo`. Result: on the Notifications tab a Concord
reply showed no quoted parent (no border) — most visibly when replying to an
image, whose target is likewise a kind-9.

RenderChat now takes unPackReply and, when FULL and not makeItShort, renders
ReplyNoteComposition(note.replyTo.lastOrNull()) like the threaded path does,
skipping it when the parent is already cited inline (`nostr:...`) so an
MLS-style quote isn't drawn twice. NoteCompose forwards unPackReply; the thread
view passes NONE since its structure already shows the parent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:58:48 -04:00
Vitor PamplonaandClaude Opus 4.8 4ec44ad241 feat(concord): harvest full member roster from bounded channel history
The members roster was a fraction of the real membership (e.g. ~13 vs ~44 on
Armada). Concord membership includes every "observed author" (CORD-02 §5 — anyone
seen publishing), but the live channel subs only carry the recent tail the relay
serves, so most members — who posted outside that tail and never sent a Guestbook
Join — never appeared.

Add ConcordMemberHarvest: a headless, run-once background sweep mounted by the
members screen that pages every folded channel's history back to a bounded window
(90 days — tunable; bounds the data pulled onto the device, per the "how far back"
limit) in one pooled `fetchAllPagesFromPool`. The wraps ride the app's normal ingest
(global CacheClientConnector → concordSessions.ingest), which folds each author into
`observedAuthors`, so the roster fills in with no extra plumbing. AUTH is free — the
channel stream keys are already registered for these relays. `beginMemberHarvest()`
gates it to once per community.

Prerequisite fix: `ConcordCommunitySession.ingest` re-decrypted a channel's WHOLE
wrap buffer on every incoming message (reprojectChannel), which is O(n²) in the
message count — fine for a ~50-wrap live tail but fatal for a history sweep. Split
it: a message now projects only its own wrap (O(1)); the re-decrypt-all path stays
for a re-fold (where channel keys can change). This also speeds the live path.
`ConcordCommunitySessionTest` now asserts the one-wrap-per-message projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:58:48 -04:00
Claude a06cdd82fe feat(concord): rich, clickable relay rows in the community create/edit form
The Concord create/edit screens showed each community relay as a bare URL string
with a remove button — far more basic than every other relay list in the app.
Extract a shared ConcordRelayListEditor that renders each relay the way the Relay
Settings / Marmot screens do: the relay's NIP-11 favicon, its advertised name, and
its host, with the row tapping through to the full relay-info page
(Route.RelayInfo) and long-press copying the URL. Both screens now call the one
editor (also removes the duplicated relay block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-15 01:54:32 +00:00
Claude b37a0ae3bb feat: add FAB to create a public chat on the Public Chats screen
Adds a "+" FloatingActionButton to the Public Chats screen (reached from
the left drawer) that navigates to the NIP-28 channel-creation flow
(Route.ChannelMetadataEdit with no id), mirroring the create-public-chat
action already offered by the Messages FAB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDKFHhUqcDJvNEA7s7Qspm
2026-07-15 01:44:21 +00:00
Claude d0a817a607 style(concord): drop bold top-bar titles to match the app's default weight
The shared TopBarWithBackButton renders its title as a plain Text (Material3's
default title weight), and screens like the Marmot group list follow that. The
Concord screens (and the minichat screen added alongside them) hardcoded
FontWeight.Bold on their TopAppBar titles, standing out from every other screen.
Remove the bold so the nav bars read consistently; content emphasis (unread
markers, names, section headers) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-15 01:42:01 +00:00
Claude 3950323ba3 fix(concord): audit fixes — memory, folding, concurrency, notifications, UI
Address the deep-audit findings across the new Concord code:

- H1: stop persisting kind-21059 ephemeral typing wraps as durable notes.
  EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was
  stored forever; drop it once the session has ingested it.
- H2: follow()/unfollow() now read the offline backup (entriesWithBackup),
  so a join racing the async backup load can no longer wipe the joined list.
- M1 (banlist): fold to the head (honors a chained unban) then union in
  authorized editions that aren't ancestors of the head — concurrent bans
  are healed without resurrecting an on-chain unban (CORD-06 down-only).
- M2: reproject only the newly-arrived channel wrap incrementally instead
  of re-decrypting the whole buffer per message (was O(n^2)); refold only
  projects newly-folded channels.
- M3: cancel a session's old state-watcher before replacing it on a
  Refounding rebuild (was a coroutine + session leak per rekey).
- M4: publish typing/state/members/observed-authors under the lock and
  make revision/observedAuthors updates atomic; clamp future-dated typing.
- M5: notification Concord bypass now requires the community to be one this
  account has currently joined (mirrors the Marmot guard).
- M6: Concord chat honors the "Messages in notifications" toggle.
- L1: carry NIP-30 emoji tags on minichat replies, image captions and
  custom-emoji reactions.
- C1: make the composer VM init() idempotent so recomposition can't wipe a
  picked image or an open suggestion list.
- C2/C3: ConcordHome channel rows and unread badges react to the channel's
  own notes flow instead of the global revision (no stale rows / flicker).
- C4: try/finally around mint-invite / create / save so a thrown call can't
  strand the button disabled.
- C5: gate the typing ticker on active heartbeats so an idle channel stops
  waking a 2s loop.

Adds regression tests for concurrent-ban union-heal and unauthorized bans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-15 00:37:13 +00:00
Claude b6a238b71a fix(concord): mute the community-name pill now that the logo is the avatar
The Concord community chip (Messages header + Notifications) no longer needs the
strong secondaryContainer highlight — the community's logo is now the row avatar,
so the name reads as faint tappable metadata (same wash as the note-header
markers). The NIP-29 relay-host chip (RelayNameChip) keeps its highlight; a relay
group has no avatar of its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 23:50:27 +00:00
Vitor PamplonaandGitHub 20479ea0ef Merge pull request #3564 from vitorpamplona/claude/eager-cannon-ykmjry
Fix NoteHeaderFirstRowDensityPreview to use tryEmit for Flow
2026-07-14 19:40:52 -04:00
Claude 0acf5359c3 fix: avoid StateFlow.value write in composition in NoteHeader preview
Use featureSet.tryEmit(...) instead of assigning .value inside the
@Composable preview, matching the pattern used elsewhere (AppSettingsScreen).
Fixes the StateFlowValueCalledInComposition lint error.
2026-07-14 23:37:47 +00:00
Claude c482af0578 feat(concord): custom-emoji autocomplete in the channel composer
Wire the shared NIP-30 custom-emoji picker into the Concord composer like the
@-mention flow: typing `:shortcode:` opens ShowEmojiSuggestionList (backed by
EmojiSuggestionState(account.emoji)); WatchAndLoadMyEmojiList loads the user's
packs. On send, account.emoji.findEmojiTags(text) attaches the NIP-30 emoji tags
to the kind-9 rumor (plain message + inline reply), so recipients render the
custom image inline via the shared chat renderer. Image uploads in messages,
icon and banner were already wired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 23:36:50 +00:00
Claude 7cd2be1c74 feat(concord): channel management + community banner & relay editing
Two ways to update a Concord community/its channels that were missing:

Channels (net-new): ConcordModeration.defineChannel writes a ChannelEntity
control edition (create/rename/delete via version chaining); Account gains
createConcordChannel/renameConcordChannel/deleteConcordChannel. The channel-list
screen gets a create FAB and a per-row rename/delete menu, all gated on
MANAGE_CHANNELS (the same predicate the fold enforces).

Community metadata: the edit screen now edits the banner (encrypted ImagePointer
upload via the shared banner hero, reusing ConcordImageUploader) and the relay
set (add/remove chips + RelayUrlEditField). Also fixes editConcordMetadata
silently dropping the banner on every save (it now round-trips it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 23:36:50 +00:00
Vitor PamplonaandGitHub 89bb4063ae Merge pull request #3563 from vitorpamplona/claude/nip29-preload-startup-92ila9
Surface NIP-29 group replies and likes in notifications
2026-07-14 19:28:48 -04:00
Vitor PamplonaandClaude Opus 4.8 d84555a27b fix(concord): real role names + full member roster (CORD-02 §5 / CORD-04)
Two roster gaps against the reference client (Armada):

1. Moderators showed as "Admin" and role definitions were often empty. The
   displayed roles came from ConcordCommunityState.roles, which was built from the
   RAW structural fold heads — so a rogue higher-version edition on a role's
   coordinate (e.g. marking the Admin role deleted) corrupted or emptied the roster,
   and even when present the UI collapsed every role-holder to a single "Admin"
   badge. Now state.roles comes from the authority-gated resolver
   (AuthorityResolver.roles(), exposed alongside rolesFor()), and ConcordMembersScreen
   renders each member's actual most-privileged role name (Admin / Moderator / custom).

2. Member count was a fraction of the real one (e.g. 10 vs ~44). CORD-02 §5: "an
   author seen publishing is observably present, auto-included even if their Join
   never arrived." The roster only counted Guestbook joiners + the privileged roster,
   omitting the bulk of members who never post a Join. ConcordCommunitySession now
   tracks observedAuthors from every decrypted channel message and folds them into
   allMembers() and the roster.

Also: amy's `concord roles/grant/ban/...` now register the control-plane stream key
before draining (like `channels`/`read`/`send` already do), so the mod verbs aren't
served an empty fold on NIP-42-gated relays — used to ground-truth the resolved roles.

Verified via amy against live Soapbox: `concord roles` now returns Admin (pos 1) and
Moderator (pos 2) instead of []. quartz + commons concord suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:13:47 -04:00
Claude af8a95c44c feat(concord): add an Open channel action to the minichat thread
A Concord minichat pins its root and backfills it from the channel history, but
when the root is still loading (or you aren't a member yet) there was no way out.
Add an Open channel action to the top bar for Concord threads, navigating to the
full channel (Route.Concord) where the whole timeline loads and membership lives
— the correct affordance to click through to the chat room itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 23:11:42 +00:00
Claude bf2b283cdc fix: keep NIP-29 group reactions in the group so likes notify
A "like" on a group message was built as a plain NIP-25 reaction — `e`
(message) + `p` (author) + `k` — with no `h` tag. The recipient's only
notification query that reaches the group's host relay,
filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their
groups (kind 7 is already in GroupNotificationKinds), so a like with no
`h` tag is never matched there. It would only surface if NIP-65 routing
happened to drop it on one of the recipient's inbox relays — never for a
host-relay-only group — so likes on group messages effectively never
notified.

Copy the target's `h` tag onto public reactions to group-scoped events,
mirroring how kind-9 replies carry it. ReactionEvent.build gains an
`initializer` (the API GroupScope's KDoc already documented); ReactionAction
applies the group `h` tag for both the tracked and fire-and-forget paths.
The like now lands on the host relay in-group and the existing kind-7
`#p`+`#h` query picks it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
2026-07-14 23:06:18 +00:00
Claude e8b7ad8f8b fix(concord): tapping a minichat reply opens its thread, not just the channel
routeFor sent every Concord-gathered note to the channel screen, so tapping a
kind-1111 minichat reply (e.g. from Notifications or the thread view) landed in
the channel instead of the reply's thread. Route a Concord CommentEvent through
minichatRouteFor (its thread); a top-level message / inline reply / reaction
still opens the channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 22:59:09 +00:00
Vitor PamplonaandGitHub dfc3c08f0b Merge pull request #3562 from vitorpamplona/claude/amy-graperank-commands-review-mofb8g
Refactor GrapeRank into separable pipeline stages with local persistence
2026-07-14 18:53:24 -04:00
Claude 86e29ed25a fix(concord): surface Concord replies/reactions on the Notifications tab
Review of the notification path (datasource + dal + display) for Concord replies
and likes that p-tag the user:

- Datasource OK: ConcordChannelPreload (mounted always-on in LoggedInPage) keeps
  every joined community's control + channel planes subscribed app-wide, so the
  wrapped reply/reaction wraps arrive regardless of tab. (The account-level
  #p=self notification sub can't see them — the outer wrap p-tag is ephemeral.)
- Projection OK: channelRumors filters only by channel/epoch binding, so kind-7
  reactions and kind-1111 replies both reach LocalCache via consumeConcordRumor.
- DAL had two gaps, now fixed:
  1. The follow-scope gate dropped notifications from community members who
     aren't in my follows (they usually aren't) unless in Global mode. Concord
     notes now bypass it like Chess/DM/Marmot — the explicit p-tag already scopes
     to genuine replies/reactions/mentions, so general chatter never leaks.
  2. Concord's default reply mode is an INLINE reply (kind-9 ChatEvent), which
     wasn't in NOTIFICATION_KINDS at all, so inline replies and @-mentions never
     notified. Added kind-9 (only notifies when it p-tags me). Minichat replies
     (kind-1111) and likes (kind-7) were already displayable.

Also let Concord notes skip the per-kind relevance heuristic (the p-tag is the
relevance signal), so a reaction still notifies when my target message hasn't
loaded yet to resolve replyTo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 22:46:52 +00:00
Claude ccd4677f5d fix: render p-tagged NIP-29 group replies on the Notifications tab
A reply to my message inside a joined NIP-29 relay group is a kind-9
ChatEvent that p-tags me (see ChannelNewMessageViewModel). It is already
fetched at startup by filterGroupNotificationsToPubkey (scoped `#p`=me +
`#h`=my groups on the group's host relay), but NotificationFeedFilter's
`acceptableEvent` checks `kind in NOTIFICATION_KINDS` before the p-tag
gate, and kind 9 (ChatEvent) was missing from that set — so those replies
were dropped before ever reaching the p-tag check and never surfaced.

Add ChatEvent.KIND to NOTIFICATION_KINDS. The existing gates handle the
rest: my own messages are excluded (author != me), the reply p-tags me
(isTaggedUser), and its `q`-tag to my message makes it pass
tagsAnEventByUser in Selected mode too. Pin the behaviour with a tripwire
test in NotificationKindsContractTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
2026-07-14 22:45:25 +00:00
Claude f628783186 refactor(cli): rename amy wot -> amy fof (follows-of-follows)
`wot` claimed the whole web-of-trust concept for what is actually a cheap
single-hop metric — the count of your follows who also follow X. The real
computed web of trust is `graperank`. Renamed the command (and WotCommand ->
FofCommand) to `fof`, reframing its KDoc/usage away from "trust," and kept
`wot` as a deprecation alias that warns and points at both `fof` and
`graperank`.

Also documents the command for the first time: `amy wot` had no --help block
and no README row. Added both (help block + three README rows for
get/list/sync), so the follows-of-follows score and its relationship to
graperank are now discoverable. JSON output shape is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
2026-07-14 22:43:44 +00:00
Vitor PamplonaandClaude Opus 4.8 83c985819f fix(concord): render the decrypted community icon (local file:// avatar)
A Concord community icon is CORD-02 §6 encrypted media: rememberConcordImageModel
fetches the ciphertext, AES-256-GCM-decrypts it, and caches the plaintext at a
local file:// path, which it hands to the avatar as the model. But
RobohashFallbackAsyncImage always wrapped the model in ProfilePictureUrl, and the
thumbnail-cache fetcher behind it (ProfilePictureFetcher) delegates a cache-miss
to Coil's http-only NetworkFetcher — so the file:// load failed and the row fell
back to the robohash, even though the icon had decrypted and cached correctly.

Only route remote http(s) pictures through the thumbnail cache; hand local/content
URIs straight to Coil's native fetchers, which load them directly. Verified
on-device: the Soapbox community icon now renders on the Messages rows and the
community header instead of a robohash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:10:21 -04:00
Claude 1a8a4012a6 docs(cli): audit graperank docs against code; simplify the --help block
Audited every graperank verb/flag in the code against the three doc
surfaces and fixed the drift:

- Main.kt `--help`: rewrote the GrapeRank block to match the terse house
  style (one line per command, key flags on continuation lines) — it had
  grown to ~68 lines of prose. Now ~33 lines, reordered into pipeline
  order (crawl -> score -> publish, then rank/status/refresh, then the
  provider/operator discovery group), with the per-verb timeout-semantics
  prose dropped (it lives in the KDoc). Verbs and primary flags verified
  against the dispatch and each function's arg reads.
- README: added the missing `graperank crawl` and `graperank score` rows
  (stage 1 and 2 of the pipeline — crawl had no table row at all, score
  was only mentioned inline) and labelled the three pipeline stages.

No stale references remain (no `graperank sync`, no removed publish/
bench flags, `operator providers`/`update` only as documented aliases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
2026-07-14 21:44:26 +00:00
Claude 65eac0f359 refactor(cli): remove the graperank sync alias; reject non-64-hex user ids
`graperank sync` is gone — `crawl` is the only spelling. Because the dispatch
treats any non-verb first token as an OBSERVER, this exposed a latent leniency
in the shared resolver: decodePublicKeyAsHexOrNull's fallback runs Hex.decode
without a length check, so a short bech32/hex-ish word like `sync` decodes to a
bogus few-byte "pubkey" instead of failing — turning `graperank sync` into a
silent network crawl over garbage. Context.requireUserHex now requires the
resolved value to be exactly 64 hex chars (a pubkey is always 32 bytes), so a
mistyped OBSERVER/USER on any amy command errors cleanly (exit 2) instead of
silently scoring/fetching against a garbage key. `graperank sync` now returns
`bad_args` rather than crawling.

Also drops the never-built `graperank compare` idea from the roadmap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
2026-07-14 21:33:49 +00:00
Claude 883365d6a5 feat(cli): graperank status verb, flag consistency, deprecate the sync alias
- New `graperank status`: read-only local inventory with no network, no
  signing, and no side effects — WoT record counts in the store (the "do I
  need to crawl again?" answer), reachability-cache size + newest-record
  age, operator/service-key state, and persisted card + retraction counts
  per observer. Reads the reachability cache through a throwaway signer so
  a fresh machine's status never lazily creates the operator master.
- Flag consistency: --relay-concurrency and --concurrency are now accepted
  interchangeably on `graperank refresh`, `graperank publish`, and
  `relay probe` (each keeps its documented spelling as canonical), and the
  help text states what --timeout means per verb (per-REQ drain 10s for
  crawl/score, idle watchdog 30s for refresh/publish, per wave 15s for
  probe, drain 8s for rank/register/providers).
- `graperank sync` now prints a deprecation warning before running crawl —
  the name points at the wrong concept since the negentropy record refresh
  became `graperank refresh` — and is removed from the docs; removal comes
  in a later release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
2026-07-14 21:18:36 +00:00
Vitor PamplonaandClaude Opus 4.8 92f422be88 fix(concord): authority-gate the control-plane fold (CORD-04 §1)
The fold selected each entity's head by STRUCTURAL fold only and never dropped
editions from unauthorized signers, so a spoofed edition that structurally
supersedes a legit one won — on the live Soapbox community this surfaced a decoy
metadata edition ("invalid ... clients should be ignoring this", no icon) over
the real owner-authorized one, so no community icon/banner ever rendered.

Two stacked wire/authority bugs, both fixed:

- RoleEntity.scope was typed String, but the reference client (Armada) writes it
  as an object ({"kind":"server"} / {"kind":"channel","channel_id":...}) per
  CORD-04 §2. The type mismatch failed the whole RoleEntity decode, dropping the
  role — and with it every grant depending on it — so no admin ever resolved.
  Added RoleScope and retyped the field (no writer set it, so no migration).

- AuthorityResolver.resolve now folds each role/grant/banlist CHAIN through
  authorized editions only, via an owner-rooted fixpoint over the full edition
  set (not the post-hoc structural heads). A rogue higher-version grant from an
  unprivileged key is dropped instead of superseding the owner's grant, so the
  legit authority stands. ConcordCommunityState.fold then gates metadata by
  MANAGE_METADATA, channels by MANAGE_CHANNELS, the banlist by BAN, and the
  dissolution tombstone to the owner alone.

Verified live end-to-end: `amy concord channels Soapbox` now folds the real
metadata (name "Soapbox Community", the blossom icon + banner pointers) and all
9 channels; fetching + AES-256-GCM-decrypting the icon pointer yields a
hash-verified PNG. Regression tests cover the object-scoped role and the rogue
superseding grant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:12:49 -04:00
Vitor PamplonaandClaude Opus 4.8 122403baf4 feat(cli): AUTH as the Concord plane stream key so amy can read planes
Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them
only to a connection authenticated AS the plane's derived stream key — the
member is neither the wrap's author (the stream key) nor its recipient (a
throwaway ephemeral key), so an account AUTH is refused and every plane REQ
came back empty (no channels, no messages), even though the community folded
its name.

Mirror the app's per-plane AUTH (60475c10c0) in the CLI:
- Context.registerConcordStreamKeys(relays, secrets) records the derived
  control/channel stream secrets, scoped to the community's relays.
- The RelayAuthenticator provider now signs one kind-22242 per registered
  stream key alongside the account AUTH — locally, from the raw derived key
  (NostrSignerSync), never the account, so no user identity is exposed.
- The concord channels/read/send verbs register their control + channel
  stream keys before draining/publishing.
- drain() gains pendingOnAuthRequired: an auth-required CLOSED keeps the relay
  pending instead of terminal, so the post-auth subscription re-fire delivers
  the events rather than the one-shot drain returning empty. The concord verbs
  opt in; all other drains are unchanged.

Verified end-to-end against the live Soapbox community (relay.ditto.pub /
relay.dreamith.to): `amy concord channels` now folds the name + 9 channels
consistently and `amy concord read` returns messages. `channels` also emits
the folded icon/banner/description pointers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:12:49 -04:00
Vitor PamplonaandClaude Opus 4.8 a4d9087187 fix(concord): include the 32-zero id in the invite bundle key (CORD-05)
CORD-05 Appendix A.1 says the HKDF `id` is always present — 32 bytes,
all-zero for a label with no meaningful id — and A.6 lists
`concord/invite-key` with `id = 0…0`. inviteBundleKey built its info with
no id at all, so it derived a different bundle key than the reference
client (Armada) and NIP-44 decryption of an Armada-minted invite bundle
failed with "Invalid Mac" — the join aborted with "no valid bundle for
this link". amy round-tripped with itself (same wrong key both sides), so
its own tests passed and the divergence went unnoticed.

Verified by decrypting a real Armada invite bundle: the no-id key fails the
MAC; the zero-id key yields a valid CommunityInvite JSON. Pass ByteArray(32)
like the sibling banlist/dissolved derivations already do.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:12:49 -04:00
Vitor PamplonaandClaude Opus 4.8 8060dc0bb1 fix(concord): fold refounded community control plane for fresh joiners
After a Refounding (CORD-06 §3), compaction re-wraps each entity's head
verbatim, so a head edited past genesis still carries an `ep`/prev citing
an edition in the prior epoch that a fresh joiner never fetches.
EditionFold.foldEntity required a genesis edition (prevHash == null) and
returned null otherwise, so a fresh login folded the whole Control Plane to
nothing: no community icon, no name, no edited channels (while Armada,
which implements the CORD-04 §1 fresh-joiner rule, showed them all).

Anchor at the lowest-version edition when no genesis is present and walk up
from there. Safe: Amethyst always re-folds the whole buffer from scratch
(no persistent floor), so it is structurally always a fresh joiner;
authority is validated on top by AuthorityResolver, so an unrooted forgery
is still dropped; and a genuine mid-chain gap still stops at the intact
prefix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:12:49 -04:00
Claude 8b822029e2 feat(cli): graperank refresh rename, unregister verb, operator keys rename
- `graperank update` -> `graperank refresh`: the verb says what it does
  (refresh the WoT record kinds from each author's outbox) and stops
  colliding with the sync/crawl naming tangle. `update` stays as an alias.
- New `graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]`:
  the missing inverse of `register` — removes matching entries (public +
  private) from the account's kind:10040 and re-publishes it; without
  narrowing flags every entry for that provider key is dropped.
- `graperank operator providers` -> `operator keys`: it lists the
  observer -> service-key map, and the old name collided with
  `graperank providers` (the kind:10040 read). `providers` stays as an
  alias; the JSON key `providers` becomes `keys` in both the listing and
  `operator status` (breaking, matches the rename).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
2026-07-14 20:52:12 +00:00
Claude a815614247 refactor(cli): move the relay census to amy relay probe
The census probes the whole known relay universe and feeds the shared NIP-66
reachability cache (kind:30166) that every reachability-aware command reads —
it was never graperank-specific, so it moves from GrapeRankCommand to
RelayCommands as `amy relay probe`. `amy graperank probe` stays as an alias,
and flags, JSON output, and behaviour are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
2026-07-14 20:42:17 +00:00
Claude 58e018c877 feat(cli): graperank scores always persist locally; add publish + rank verbs
Every `amy graperank` / `graperank score` run now reconciles its result into
the local event store as NIP-85 kind:30382 cards signed by the per-observer
service key (cutoff --min-rank, default 2): changed ranks are re-signed,
unchanged ones skipped (no event-id churn), dropped targets retracted with a
kind:5 the store applies on insert. The store is the source of truth, so the
score of a run is durable and reusable instead of ephemeral stdout.

New verbs complete the crawl -> score -> publish pipeline:
- `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only:
  one NIP-77 up-only reconcile per operator relay over the provider key's
  kind:30382 + kind:5, converging the relay to the local set (deletions
  propagate, lost cards restored, full-set publish fallback for relays
  without negentropy); also refreshes the observer's kind:10040 pointer.
- `graperank rank USER [--provider P] [--refresh]` — the consumer side:
  newest card per provider from the local store, with a relay drain on miss.

GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is
replaced by reconcileLocal (sign+insert+retract against the store) and
syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a
commonTest covering upsert/skip/retract and re-carding a retracted target.

BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay`
and `--bench-sign` are removed from the score command. Its JSON drops
published/publish_rejected/deleted/delete_rejected/skipped_unchanged/
publish_truncated/published_kind/published_to/publish_error/observer_10040/
bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/
cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to
the new `graperank publish` verb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
2026-07-14 20:29:09 +00:00
Claude 6f63b33119 feat(concord): show the community pill on Concord notifications
Extract the Messages row's highlighted community chip into a shared
ConcordCommunityPill (secondaryContainer, group icon, 20-char cap) and render it
in NoteCompose's header markers for any Concord-gathered note, so a Concord
message surfaced in Notifications names its community and taps through to the
channel list — the same chip, wherever the message appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 20:17:15 +00:00
Claude 24c2cfedc6 fix(concord): reply from Notifications/feed goes through the channel plane
routeReplyTo special-cased Marmot groups but had no Concord case, so replying to
a Concord message outside a chat screen (e.g. from Notifications) fell through to
a public kind-1111 comment — leaking the private rumor id onto public relays and
producing a reply that doesn't bind to the channel and no member would see.
Route a Concord-gathered note's reply into its minichat (its thread root for a
kind-1111, the message itself for a kind-9), whose composer sends the wrapped
kind-1111 on the channel plane. (Reactions already route through
reactToConcordMessage; zaps are forced PRIVATE via isPrivateRumor since a Concord
rumor is unsigned.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 20:17:13 +00:00
Vitor PamplonaandGitHub 9d5bd48164 Merge pull request #3561 from vitorpamplona/claude/nip-29-channel-icons-8h1dop
Warn users when relay group relay doesn't advertise NIP-29
2026-07-14 16:16:15 -04:00
Claude 64e0fa2a91 fix(concord): restore the Messages server/community chip's highlight
PR #3559 muted the shared HeaderPill (correct for NoteCompose's PoW/OTS/location
markers), but that also flattened the Messages tab's server/community chip, which
is a first-class navigation entry point and should stand out. Give RelayNameChip
back its own secondaryContainer highlight instead of the muted pill, and hard-cap
the community name at 20 chars so a long title can't crowd the row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 20:08:20 +00:00
Claude 5507b1debf feat: bigger relay icons and NIP-29 support warnings on Find Groups
The relay rows on the Find Groups (browse) screen now use the 55dp
LargeRelayIconModifier, matching the avatar size of the Messages list,
instead of the 13dp default.

When a relay's NIP-11 document resolves with a supported_nips list that
lacks "29" and no self key, the row shows a 'May not support groups
(NIP-29)' warning, and the relay's (empty) group directory screen says
the relay doesn't advertise NIP-29 instead of the generic empty text.
The check lives in looksLikeNonNip29Relay next to the other NIP-29
NIP-11 helpers; a null supported_nips (doc still loading or fetch
failed) never warns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6
2026-07-14 20:06:20 +00:00
Claude 63f26b5cb4 feat: fall back to the relay's NIP-11 icon for NIP-29 rooms without a picture
In the Messages list, a NIP-29 group whose kind-39000 metadata has no
(or a blank) picture now shows its host relay's NIP-11 icon instead of
jumping straight to the robohash fallback. loadRelayInfo fetches the
NIP-11 document on demand if it isn't cached yet, and the robohash
still kicks in when the relay has no icon either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6
2026-07-14 19:55:12 +00:00
Claude e734d6400c feat(concord): send & receive encrypted image messages (Armada-compatible)
Concord channel messages can now carry images, wire-identical to Soapbox
Armada's `encryptAttachments`: a normal channel-bound kind-9 whose ciphertext
URL is appended to the content and annotated by a NIP-92 `imeta` tag with
`encryption-algorithm aes-gcm`, hex `decryption-key`/`decryption-nonce`, and the
plaintext `ox` hash (no `x`). The blob is AES-256-GCM ciphertext on Blossom, so
the media host and relays only ever see encrypted bytes — the community's E2E
guarantee holds.

Reuses the NIP-17 encrypted-media stack end to end: quartz's imeta tag vocab
and IMetaTagBuilder to build/parse the tag (ChannelChat.imageMessage /
encryptedImageImeta / encryptedImagesOf), the shared UploadOrchestrator
encrypted upload + ChatFileUploadDialog picker on the send side, and the OkHttp
EncryptedBlobInterceptor keyCache on the receive side — registering each
attachment's cipher (keyed by URL) lets the normal feed renderer display the
decrypted image with no shared-render changes. Encryption is mandatory
(no toggle, and a missing cipher fails closed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-14 19:44:56 +00:00
Vitor PamplonaandGitHub 96c1a2ee2d Merge pull request #3555 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-14 11:43:37 -04:00
Claude e231b0cf5b Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-14 15:25:40 +00:00
vitorpamplonaandgithub-actions[bot] 5e41a26678 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-14 15:22:01 +00:00
Vitor PamplonaandGitHub 8fc841e50e Merge pull request #3559 from vitorpamplona/claude/notecompose-pow-pill-style-vr6hbt
Unify note-header markers into a pill / quiet-mark design system
2026-07-14 11:19:34 -04:00
Vitor PamplonaandGitHub 596cb24de4 Merge pull request #3553 from vitorpamplona/claude/large-screen-layout-v5otrb
Adaptive large-screen layout: nav rail, permanent drawer, notification panel, reading-column cap
2026-07-14 11:19:10 -04:00