Commit Graph
16603 Commits
Author SHA1 Message Date
Claude b83ef20d61 feat(cli): amy concord roles/role/grant/ban/unban (CORD-04 moderation)
Adds the moderation verbs over the tested ConcordModeration write path:
- roles <community>            list live roles + current banlist
- role <community> <name> <pos> PERM...       define a role (perms by name), prints role_id
- grant <community> <user> <roleId>          grant a role to a member
- ban / unban <community> <user>             banlist add/remove

Each drains the Control Plane, chains the next edition onto the current head,
and publishes it; authority is enforced on fold by every client. Users resolve
via npub/nprofile/hex/nip05 (ctx.requireUserHex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 22:00:12 +00:00
Claude 441413ea95 feat(concord): roles & moderation write path (CORD-04)
ConcordModeration builds the Control Plane editions for defining a role,
granting roles to a member, and banning/unbanning — each a kind-3308 edition,
plaintext-sealed and wrapped on the Control Plane. It chains the next version
onto the entity's current head (version+1, prevHash=head.hash) and unions the
banlist, taking the community's current editions as input.

Authority is enforced at fold time by the AuthorityResolver, not here: a round-
trip test confirms an owner can define an Admin role, grant it (member gains BAN),
ban/unban (version chaining heals), and that a grant forged by a non-outranking
troll is dropped by the fold.

- ConcordActions.controlEditions opens control wraps into editions
- ConcordCommunitySession exposes controlEditions()/controlPlaneKey() so edits
  chain onto live state
- Account.grantConcordRole / banConcordMember / unbanConcordMember publish the
  edition with an instant local echo (the session refolds it)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 21:56:06 +00:00
Claude 3895dbe115 feat(concord): create-community + invite UI, Concord hub
Wires the create/invite Account actions (which had no UI) into screens:

- ConcordCreateScreen: name/about/relays form → createConcordCommunity → opens
  the new community.
- ConcordHomeScreen (Route.Concords): the Concord Channels hub — lists joined
  communities with a Create FAB. Documents that Concord has no public directory
  (E2E-encrypted, invite-gated), so there is intentionally no browse feed.
- ConcordChannelListScreen: an "invite people" top-bar action mints a kind-33301
  bundle and shows the shareable link with copy-to-clipboard.
- Reachable from the Messages new-chat FAB menu.

Note on the planned discovery feed: the kind-33301 bundle exposes only
`["d",""],["vsk","6"]` publicly; name/relays/secrets are NIP-44-encrypted under
the per-invite token, and it is signed by an ephemeral link key — so there is no
public metadata to browse or filter. A GitRepositories-style discovery feed is
not applicable; discovery is invite-link-only by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 21:41:16 +00:00
Claude e85de73e60 feat(concord): plane-wrapped reactions and replies (outbound)
Receiving reactions/replies already worked (they decrypt via channelRumors and
wire to their target Note by id). This adds the send side, which must NOT go
through the generic public/NIP-17 reaction path — a plaintext kind-7 would e-tag
the private rumor id onto public relays, and the NIP-17 path wraps to named
recipients, not the channel plane.

- ChannelChat.reaction/reply build kind-7/kind-9 rumors bound to channel+epoch
  (reaction e-tags the target; reply q-tags the parent)
- ConcordActions.buildChannelReaction/buildChannelReply wrap them on the plane
- Account.reactToConcordMessage + sendConcordChannelMessage(replyTo) publish the
  wrap with an instant local echo, factored through publishConcordWrap
- AccountViewModel.reactToOrDelete intercepts Concord notes (detected by the
  ConcordChannel gatherer) and routes to the plane-wrapped reaction
- ConcordChannelScreen wires onWantsToReply into the composer with a reply banner

Zaps already route correctly: a Concord message is an unsigned rumor, so the
existing isPrivateRumor() path forces a PRIVATE (NIP-57 encrypted) zap, same as
NIP-17 DMs. A fully on-plane nutzap (no public receipt) remains a future upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 21:20:07 +00:00
Claude 65502c7cda refactor(concord): land decrypted messages in LocalCache as real Notes
Follows the NIP-28/NIP-17 model instead of a private per-session flow. The
community session is now purely a decrypt-and-validate gate: each validated
inner rumor is handed to a sink (ConcordRumorSink) that the app wires to
LocalCache.consumeConcordRumor — landing it as a real Note keyed by rumor id
and attaching message-like kinds (9/1111) to the ConcordChannel.

Consequences: messages are first-class Notes, so reactions (kind 7), replies
(1111), deletes (5), OTS and zaps wire up automatically by id through the normal
consume path; the Messages inbox shows real last-message previews and updates
incrementally (filterRelevantConcordMessages, keyed on the ConcordChannel
gatherer) instead of feed() rebuilds; and the chat screen reuses the shared
ChannelFeedViewModel + RefreshingChatroomFeedView (full note UI) with only the
Concord-specific encrypted send path kept bespoke.

- ConcordActions.channelRumors returns validated bound rumors (all chat kinds)
- ConcordCommunitySession/SessionRegistry/SessionManager thread the sink; the
  messagesFlow/ConcordChatMessage projection is gone from the session
- LocalCache.consumeConcordRumor attaches gatherer before justConsume so the
  inbox incremental filter can route the row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 20:56:32 +00:00
Claude 8f0cbc77cf feat(concord): tappable Concord invite links in note content
RichTextParser now classifies `…/invite/<naddr>#<fragment>` URLs as a
ConcordInviteLinkSegment (cheap substring gate before the base64/bech32 parse),
and RichTextViewer renders them via ClickableConcordInviteLink — tap opens the
redeem flow, long-press copies the link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 20:00:32 +00:00
Claude 9964423ebc feat(concord): create, mint-invite, and deep-link join flows
Account gains createConcordCommunity (mint genesis, publish, join),
mintConcordInvite (publish kind-33301 bundle, return the shareable link), and
joinConcordViaInvite (parse link, fetch+unlock bundle, add to the 13302 list).

ConcordInviteScreen auto-redeems an invite deep link (Route.ConcordInvite) and
forwards to the joined community's channel list, with a retry on relay miss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 19:56:19 +00:00
Claude 1e5d062e32 feat(concord): chat + channel-list screens, send path, nav routes
- ConcordChannelScreen: renders a channel's decrypted message flow with a
  composer; posting derives the channel plane key and publishes an encrypted
  wrap to the community relays (Account.sendConcordChannelMessage), with an
  instant local echo via the session fold.
- ConcordChannelListScreen: the community "server" view listing folded channels.
- Registers the ConcordChannelFilterAssembler in RelaySubscriptionsCoordinator
  and wires Route.Concord / Route.ConcordServer into AppNavigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 19:51:55 +00:00
Claude 285317b0c2 feat(concord): surface Concord Channels on the Messages screen
Adds a 6th concat to ChatroomListKnownFeedFilter: each folded Concord channel
is its own Messages row (placeholder note carrying its ConcordChannel gatherer),
interleaved with DMs and other chats. ChatroomHeaderCompose renders it via a new
ConcordRoomCompose showing the channel name plus a community chip that opens the
community's channel list; tapping the row opens the encrypted chat.

Adds Concord / ConcordServer / ConcordInvite / Concords navigation routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 19:44:26 +00:00
Claude f450d03110 feat(concord): mount live channel subscription + LocalCache index refresh
Adds ConcordChannelFilterAssembler (kind-1059 authors=[planePk] per relay,
control planes upfront + channel planes once a Control Plane folds) and the
ConcordChannelSubscription composable that, on each ConcordSessionManager
revision, refreshes the LocalCache ConcordChannel rows from the freshly-folded
state and re-derives the filters to pick up newly-revealed channel planes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 19:40:15 +00:00
Claude 8f34f5f7d2 feat(concord): wire ConcordSessionManager into Account + giftwrap route
Adds ConcordSessionManager (commons) that keeps one folding session per
joined community in step with the concord list and turns folds into an
observable revision the app watches to re-derive subscription filters.

Account owns one per account; the giftwrap decrypt path (GiftWrapEventHandler)
routes recognized Concord plane wraps to it before the NIP-59 DM check, so
ephemeral-p Concord wraps fold instead of being dropped as undecryptable DMs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 19:38:10 +00:00
Claude faa9aa732e feat(concord): add ConcordSessionRegistry account-wide coordinator
Holds one live ConcordCommunitySession per joined community, fans inbound
kind-1059 wraps out to the owning session, and exposes the union of
control/channel plane addresses to subscribe. sync() reconciles sessions
against the joined list while preserving already-folded state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 19:30:55 +00:00
Claude 53c3a92288 feat(concord): add ConcordCommunitySession read-model
The live, stateful read-model of one joined community that a screen/ViewModel
binds to and a subscription feeds — bridging the pure logic to the UI:

- derives the Control Plane address up front (from the entry's secrets)
- ingest(wrap) routes by stream address: control wraps re-fold into a
  state StateFlow (metadata + channels + authority) and re-derive each channel's
  Chat Plane address; channel wraps re-project into per-channel message flows
- exposes controlPlaneAddress + channelAddresses() (what to subscribe to),
  state, membership(), and messagesFlow(channelId)
- thread-safe; unknown wraps (other communities) are ignored

Test: feed genesis control wraps -> "Nostrichs" + #general + OWNER membership +
the general plane becomes a known address; feed a channel message -> it lands in
#general's flow; a stray community's wrap is ignored. Green on :commons:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 19:23:43 +00:00
Claude 6178a4e3c6 feat(concord): add ConcordPlaneRegistry (route + decrypt inbound wraps)
The read-path counterpart to the subscription planner: maps derived plane
addresses (group_key.pk) to the keys that open them, so an inbound kind-1059 wrap
is recognized as Concord traffic and decrypted with the right per-plane key.

- registerControlPlanes(entries): control addresses, known from secrets alone
- registerChannels(entry, foldedState): channel addresses, known after the
  Control Plane folds
- route(wrap): if wrap.pubkey is a registered plane, open+verify and return the
  routed rumor with its plane kind/community/channel; else null

Because the wrap p-tag is ephemeral, address matching is the only route — a
non-member never registers the address, so never decrypts. Thread-safe.

Test routes a genesis control wrap to CONTROL and a channel message to CHANNEL
(right channel id + decrypted content), and rejects a wrap from another
community. Green on :commons:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 18:51:08 +00:00
Claude a1ae4220e9 feat(concord): add subscription planner (warm all joined planes)
The commons half of the "keep channels live" subscription, mirroring NIP-29's
RelayGroupMyJoinedGroupsFilterAssembler: turns the account's joined-communities
list into per-plane REQs by derived stream address (there is no #p=me for
Concord, so each plane is fetched by authors=[planePk]).

- ConcordSubscriptionPlanner.controlPlaneSubs: one Control Plane sub per joined
  community (known from the entry's secrets alone)
- channelPlaneSubs: one Chat Plane sub per channel once the Control Plane folds
- filtersByRelay: collapses subs to one kind-1059 author filter per relay
- ConcordActions.planeFilterFor(authors): multi-author plane filter

Test derives a community, asserts the control/channel sub addresses equal the
derived plane pks and that the per-relay filter carries both. Green on
:commons:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 18:48:12 +00:00
Claude e560df6edc feat(concord): add ConcordChannel model + LocalCache index
The channel model the shared chat UI renders, mirroring RelayGroupChannel:

- quartz ConcordChannelId(communityId, channelId): the stable channel address
  (analog of NIP-29 GroupId, but community-scoped rather than relay-pinned)
- commons ConcordChannel : Channel — name/voice/private from the folded Control
  Plane (no single relay-signed metadata event), relays() = the community relay
  set (a plane may be mirrored on several), membership from the authority
  resolver, canPost(), placeholderNote() for immediate Messages-list rows;
  updateFrom(state, relays, myPubKey) refreshes on each re-fold
- LocalCache: concordChannels LargeCache index + getOrCreate/getIfExists

Compiles across :quartz/:commons/:amethyst. Next: the decryption subscription
that folds inbound channel-plane wraps into these, then the Messages inbox hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 18:25:52 +00:00
Claude 22e131e51c feat(concord): wire ConcordChannelListState into Account (kind-13302 home base)
The per-account home base for Concord Channels, mirroring RelayGroupListState:

- commons ConcordChannelListState observes the kind-13302 addressable note,
  exposes liveCommunities (joined secret-bearing entries) and liveServers
  (community ids), and follow/unfollow read-modify-write the self-encrypted list;
  a ConcordListRepository backs offline restore
- AccountSettings implements ConcordListRepository (backupConcordList +
  concordList/updateConcordListTo) and gains a concordViewMode setting
- Account instantiates concordChannelList and exposes joinConcordCommunity(entry)
  / leaveConcordCommunity(id), publishing the list via the account outbox

Concord's "in-between" shows here: same wiring as the NIP-29 list, but entries
carry the community secrets (decrypt yields root/salt/epoch/channel keys), so one
self-encrypted event both syncs membership and re-derives every plane. Compiles
across :commons and :amethyst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 18:11:17 +00:00
Claude 88732d507c feat(concord): make joined-list a registered Event (13302) for cache/account use
Promote the kind-13302 joined-communities list to a proper Event subclass so the
Android LocalCache/Account layer can observe it the way RelayGroupListState
observes SimpleGroupListEvent (10009):

- ConcordCommunityListEvent : Event — createAddress (13302, pubkey, ""), a
  suspend create(signer, entries), and decrypt(signer); content stays NIP-44
  self-encrypted and carries each community's secrets
- register the kind in EventFactory so inbound events parse to the typed class
- extract encode/decode JSON helpers in ConcordCommunityList (shared by both)

Test: create -> the wire form hides the name, a JSON round-trip resolves to the
typed class via EventFactory, decrypt recovers entries, and the replaceable
address is (13302, pubkey, ""). Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 17:36:49 +00:00
Claude 0306633eab docs(concord): document per-account persistence + subscription model
Concord sits between NIP-17 and NIP-28/29: NIP-28/29-style author-addressed
plane subscriptions (never #p=me, since the wrap p-tag is ephemeral) + NIP-17
shared-key E2EE + a kind-13302 self-encrypted membership list that also carries
the community secrets. Home base = ConcordChannelListState over 13302 (mirrors
RelayGroupListState/EphemeralChatListState, but entries hold keys); LocalCache
projects live ConcordChannels; a per-plane author-subscription assembler keeps
them fresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 17:23:29 +00:00
Claude 4672b60c9d feat(concord): add mobile foundation enums (membership + view mode)
First slice of the Android integration, mirroring NIP-29's commons model:

- ConcordMembership (OWNER/ADMIN/MEMBER/BANNED/NONE) derived from the folded
  owner-rooted AuthorityResolver + banlist — membership is key possession, roles
  layer moderation power, the banlist removes standing; with isMember/canModerate
- ConcordViewMode (INLINE/GROUPED) for how joined channels surface in Messages

Test classifies owner/admin/member/banned/stranger from a folded control plane.
Green on :commons:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 17:19:36 +00:00
Claude b816842e5d docs(concord): add mobile integration plan mirroring NIP-29 relay groups
Blueprint for the Android app layer, cloning the just-merged NIP-29 relay-groups
touch points with Concord equivalents: commons ConcordChannel + kind-13302
ConcordChannelListState, Account/LocalCache wiring, the 6-way Messages-inbox
concatenation + synthetic server row (chip opens the channel), the reused NIP-28
ChannelView chat screens, nav routes, a GitRepositories-style discovery feed, and
notification routing + on-plane zaps/likes.

Documents the one structural difference from NIP-29: Concord communities are E2EE
(plane-pubkey addressing, no public relay-signed metadata), so discovery surfaces
public invite links rather than browsable metadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 17:17:28 +00:00
Claude 51494d5330 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-10 17:05:56 +00:00
Vitor PamplonaandGitHub 525d3ef189 Merge pull request #3514 from vitorpamplona/claude/armada-nip29-integration-lwqard
Relay Groups: NIP-29 relay-based group chat (Armada interop)
2026-07-10 13:03:48 -04:00
Claude f946e1aaf4 fix: use the Topic icon for the composer's subject toggle
The subject/title toggle used the Article (document-lines) glyph, which reads as
"body text". Switch to Topic — a clearer signifier for a subject/title line.
Codepoint already present in MaterialSymbols, so no font-subset regen needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:50:23 +00:00
Claude 003142122b fix: don't drop NIP-11 waiters on a stuck Loading marker
The relay-signed NIP-29 gate reads each relay's NIP-11 `self` key from the
cache. Nip11CachedRetriever.loadRelayInfo treated a valid `Loading` marker as
"a fetch is already in flight, just wait" — but it dropped the caller's
callback entirely and had no way to notify it when the fetch finished.

That is a lost-wakeup: if the coroutine that started the fetch is cancelled
mid-flight (e.g. the discovery screen that launched the warm-up leaves
composition when you tap a relay chip), the `Loading` marker is left behind,
valid for a full hour. The relay's on-group-list screen then mounts, sees the
stuck `Loading`, waits forever, and never receives the doc — so its NIP-11
looks empty (no `self`), the self-key gate rejects every 39000, and a group
you can plainly see under the "Mine" filter (which bypasses the gate) vanishes
on the relay page.

Re-fetch on `Loading` instead of silently waiting: the fetch is cheap, dedups
at the HTTP layer, and guarantees this caller is notified. Error caching is
unchanged (still avoids hammering a genuinely broken relay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:27:02 +00:00
Claude af0e9c512e fix: warm host-relay NIP-11 in discovery so All-Follows shows their groups
Companion to the All-Follows roster fix: the discovery screen warmed NIP-11
only for the outbox `candidateRelays`, but a group's relay-signed 39000 lives
on its HOST relay. In All-Follows the subassembler now probes those host
relays (joined kind-10009 + favorited kind-10012) for follow rosters, so their
39000s land in the cache — but the self-key gate (isRelaySignedRelayGroup)
then read an unwarmed, empty NIP-11 (self=null) and dropped every one of them.

That is why the groups only appeared after bouncing through Global (whose
relay set happens to include the host relay, warming it as a side effect).
Warm the joined + favorited host relays alongside the outbox candidates so
the gate is a cache hit with the relay's `self` key present.

Verified against wss://basspistol.org: NIP-11 advertises nip-29 with
self=afd7da3f…, all six 39000s are signed by that self key, and
`amy relaygroup browse` returns all six with unverified_dropped=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:15:28 +00:00
Claude 4d81d298ec fix: load follow-participated relay groups in All-Follows without Global
The All-Follows / Authors discovery filter sets resolve their relays via
the outbox model (each follow's own publish relays), but a NIP-29 roster
(kind-39001 admins / 39002 members) lives ONLY on the group's host relay.
So a follow who is an admin or member of a group never surfaced in
All-Follows until the user bounced through Global — which pulls the whole
directory — and back.

Additionally query the follows as `#p` against the group-host relays we
already know about (joined via kind-10009 + favorited via kind-10012),
minus the relays the outbox filter already covers, and re-assemble when
either list changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:08:21 +00:00
Claude babdea67ab feat: list joined groups' relays as filter chips in Relay Groups discovery
You had to Favorite a relay (kind-10012) before it appeared as a chip in the
Relay Groups top-nav filter — even for relays you already have groups on. Now the
host relay of every group in your joined list (kind-10009) also shows up as a
relay chip, so you can browse the other groups on those relays without favoriting
them first.

Added only to the relay-groups discovery catalog (not git/podcasts/etc.), deduped
against relays already present as favorites. Selecting one resolves to that
relay's groups (filtered by the relay-signed self-key check as usual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:51:51 +00:00
Claude fa0883f4a0 perf: warm NIP-29 relays' NIP-11 in parallel + pre-warm joined groups
The relay-signed group check reads each host relay's NIP-11 `self` from cache,
but the discovery screen was warming those docs SERIALLY — Nip11Retriever awaits
each HTTP fetch, so N relays meant N sequential round-trips and one slow or
unreachable relay stalled every group behind it until its socket timeout. That's
why groups trickled in.

- Add WarmNip11(relays): fans the fetches out, one coroutine each, so the wait is
  the slowest single fetch instead of the sum. Discovery now uses it and
  re-invalidates the feed as each doc lands (via a version counter).
- Pre-warm NIP-11 for joined groups' host relays from the Messages tab
  (WarmJoinedRelayGroupNip11), so by the time one surfaces in discovery the
  answer is a cache hit. NIP-11 stays cached for an hour.

Note: the Messages tab itself never needed this — joined ("My Groups") rows are
authoritative from the kind-10009 list and were never gated on NIP-11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:43:20 +00:00
Claude 0f01dc0c12 feat: verify NIP-29 group metadata is signed by the relay's own key
NIP-29 metadata/roster events (39000-39003) "are addressable events signed by
the relay keypair directly ... as stated by the NIP-11 `self` pubkey", and
"relays shouldn't accept these events if they're signed by anyone else". So the
authoritative test for a genuine group is `39000.author == relay.self` — which
also rejects a stray user-published 39000 even on a real NIP-29 relay, something
the earlier supported_nips heuristic could not.

Add `isRelaySignedRelayGroup(channel)`: strict `author == self` when the relay
publishes `self`, falling back to `supported_nips ∋ 29` when it omits `self`, and
false when it has neither. Apply it at the surfaces that show unsolicited groups:

- Discovery feed: replace the relay-level supported_nips filter with the
  per-channel self-key check in matches(); the screen now warms each candidate
  relay's NIP-11 and re-invalidates the feed as each doc resolves.
- On-relay group list: filter to relay-signed groups, warming that relay's
  NIP-11 so genuine groups fill in and fakes stay hidden.
- CLI `relaygroup browse`/`info`: fetch the relay's NIP-11 (new
  Context.relayInfo) and drop 39xxx not signed by `self`; browse reports the
  dropped count.

Explicit user actions (a received invite link, opening an naddr) are left
untouched — hiding those would be user-hostile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:18:13 +00:00
Claude 188ba3c609 feat: discovery only shows groups from relays that advertise NIP-29
Stray kind-39000 events published by ordinary users to general relays (e.g.
nostr.wine) were surfacing in the Relay Groups discovery feed as joinable groups
that have no roster and no chat and can't actually be joined — because those
relays don't run NIP-29, they just store the fake metadata like any addressable
event.

A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a relay
every 39000 is relay-signed and genuine. Gate discovery on that: restrict the
per-relay constraint set to relays whose NIP-11 `supported_nips` advertises 29.
This is the single point both the match test and the REQ-driven feed read, so
non-advertising relays drop out wholesale. The discovery screen warms each
candidate relay's NIP-11 and re-invalidates the feed as support resolves (a
relay whose NIP-11 lands after its 39000s would otherwise stay hidden until a
manual refresh). "My Groups" (the kind-10009 joined list) is unaffected.

Trade-off: a relay that runs NIP-29 but doesn't publish NIP-11 (or omits 29 from
its list) is hidden from discovery until it advertises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 14:32:30 +00:00
Claude 9a5a278b01 feat: block NIP-29 group creation on relays that don't advertise it
The reported "title/image didn't save, group shows as a bare hex, and I'm asked
to join my own group" all come from creating a group on a relay that isn't
running NIP-29: it stores the 9007/9002 as ordinary events but never creates the
group, emits 39000/39001/39002 metadata, or makes the creator an admin.

Gate the create screen on the relay advertising NIP-29 in its NIP-11
`supported_nips`: a tri-state check (checking / unsupported / supported) disables
the Create button until support is confirmed and shows an explanatory warning
banner when the relay is confirmed to lack it. Editing is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 13:13:30 +00:00
Claude 567cc3f416 style: match the composer subject/title field to the new-DM To field
Restyle the ShortNotePostScreen subject/title input to the inline-label +
borderless ThinPaddingTextField + hairline-divider look used by the new-DM
composer's "To"/"Subject" rows, instead of a boxed OutlinedTextField. The field
now backs onto a TextFieldState (like the DM composer) rather than a String.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:29:41 +00:00
Claude d650c184de feat: require the title for NIP-29 group threads (no text fallback)
The group-thread composer now treats the subject/title field as mandatory: the
field is always shown (no toggle to hide it), canPost() blocks until it's filled,
and createTemplate uses it verbatim as the kind-11 title — dropping the previous
first-line-of-the-body fallback. Plain kind-1 notes keep the optional subject.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:21:25 +00:00
Claude bf288711a2 feat: optional subject/title field in the ShortNotePostScreen composer
Add a subject line toggled from the composer's bottom row. On a kind-1 note it
becomes a NIP-14 `subject` tag; on a NIP-29 kind-11 group thread it becomes the
`title` (superseding the first-line-as-title heuristic — that stays as the
fallback when the field is left empty). The field is auto-shown for group
threads and labelled "Title" there, "Subject" otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:14:05 +00:00
Claude cb90362bae feat: warm each group's recent messages on a relay's channel list
Opening a group from the relay's channel list used to start its chat from a cold
load. Mount the existing RelayGroupWarmupSubscription on every visible card
(content-only — the directory subscription already streams metadata), so a tap
lands on already-cached messages.

Add a contentLimit to the warmup (default 50, unchanged for discovery's "50+"
signal); the channel list passes ~10 — a first screen's worth. Bounded to
visible rows by the LazyColumn and released as they scroll off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:13:53 +00:00
Claude dd9623a16e feat(concord): add amy concord CLI (create/join/send/read/invite)
Full Concord stack through the CLI, thin over commons ConcordActions + Context:

- ConcordStore (~/.amy/<account>/concord.json, 0600): joined communities +
  their secrets for re-derivation across runs
- concord create   — mint community + publish genesis, save locally
- concord list      — list joined communities
- concord channels  — drain + fold the Control Plane, list channels
- concord send      — post an encrypted kind-9 message to a channel
- concord read      — drain + decrypt a channel's messages (oldest-first)
- concord invite    — mint + publish a shareable invite link (bundle 33301)
- concord join URL  — fetch + decrypt the bundle with the fragment token, save

Verified end-to-end against a local `amy serve` (geode) relay: Alice creates a
community and mints an invite; Bob joins from the URL alone, both post to
#general, and both read the identical decrypted message list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 23:43:50 +00:00
Claude 1ecc97de76 feat: compose NIP-29 group threads in the full ShortNotePostScreen
The group "new thread" FAB opened a cramped title+body screen. Point it at the
rich ShortNotePostScreen instead (attachments, emoji, previews, markdown), and
teach that composer to emit a kind-11 group thread when opened for a group.

- Route.NewShortNote gains groupThreadId + groupThreadRelayUrl; the group Threads
  FAB navigates there.
- ShortNotePostViewModel.setGroupThread arms a group-thread mode: createTemplate
  builds a kind-11 ThreadEvent (title = first line, body = the rest, `h` scope),
  and sendPostSync publishes it ONLY to the group's host relay via
  signAndSendPrivatelyOrBroadcast — bypassing the outbox/private/scheduled paths.
- The poll, private-note and scheduling toggles are hidden in group-thread mode
  (they don't apply / would break the host-relay pin).
- Delete the now-unused RelayGroupNewThreadScreen and its route/nav dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 23:35:40 +00:00
Claude 587387ba96 feat(concord): add commons ConcordActions (CLI-safe business layer)
Pure builders + relay-filter assembly + folding for Concord, usable from amy CLI
and the Android app (like DmActions, it never touches the network):

- plane key derivation (controlPlane/publicChannel)
- relay filters (planeFilter, bundleFilter, directInvitesFilter)
- createCommunity, foldCommunity (open control wraps -> editions -> live state)
- buildChannelMessage + channelMessages (open, bind-check, order oldest-first)
- invite helpers: inviteFor, mintInviteLink, parseInviteLink, openBundle
  (decrypt+validate), controlPlaneFor

Test covers the create -> fold -> send -> read round-trip and the mint -> parse
-> open -> read invite flow. Green on :commons:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 23:34:15 +00:00
Claude 6292ceb803 feat: grouped-by-relay Messages view + a Messages settings screen
Move the NIP-29 inline/by-relay toggle off the top of the Messages tab (where it
sat clipped behind the tab row) into a new Settings › Messages screen, and make
"by relay" actually mean something in the feed.

- New MessagesSettingsScreen (Route + SettingsCatalog entry + nav) with a
  radio choice: show each joined group inline, or collapse each relay's groups
  into one row. Removes the pinned SegmentedButton + above-pager server list
  from both the single- and two-pane layouts; deletes the now-dead
  RelayGroupViewModeToggle and RelayGroupServerList composables.
- GROUPED mode now weaves one row PER HOST RELAY (never duplicated) into the
  Messages feed, positioned at that relay's newest group message so it
  interleaves with DMs by recency and shows the last message. Backed by a
  synthetic RelayGroupServerRoomNote whose createdAt mirrors the newest message;
  ChatroomListKnownFeedFilter builds/updates it in feed(), applyFilter and
  updateListWith, keyed by relay url. A view-mode change forces a feed rebuild.
- Revert the moot kind-7 render guard: the data-layer content filter already
  keeps reactions out of the row, so the ChatroomEntry fallback stays simple
  (the shared Event.isGroupChatContent helper remains, used by the feed filter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 23:17:19 +00:00
Claude d4e71881e8 fix: sign a q quote-tag when replying to a kind-9 group message
The NIP-29 group composer always built replies with ChatEvent.build and ignored
replyTo entirely, so a reply to a kind-9 message quoted its parent in the UI but
the signed event carried no NIP-18 `q` tag (and no `p` notify to the author) —
unlike the public-chat and live-activity paths, which use `.reply(...)`.

Route group replies through ChatEvent.reply when replyTo is set: it emits the
`q` quote tag, and we add a `p` tag to the parent's author.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 23:17:03 +00:00
Claude 46a2652fc5 feat(concord): add direct invites (kind 3313)
Completes CORD-05: for a known npub, deliver the CommunityInvite as a NIP-59
giftwrap instead of a public bundle — a kind-3313 rumor sealed (kind 13) to the
recipient and wrapped (1059) with ["p", recipient] and a ["k","3313"] index tag
so recipients can query pending invites without decrypting every giftwrap.
Cannot be revoked (recipient holds the keys on arrival).

Reuses SealedRumorEvent + the giftwrap primitives. Tests cover round-trip to the
intended recipient (with p/k tags) and that strangers cannot open it. Green on
:quartz:jvmTest.

With this, the Quartz protocol layer covers CORD-01..07 end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 23:11:22 +00:00
Claude 5ea59ca137 feat(concord): add voice presence and blind-broker token (CORD-07)
- VoicePresence: kind-23313 join/left presence rumors bound to the channel/epoch
  and carrying the SFU identity + broker, with heartbeat/stale constants and a
  verifiedParticipants fold that renders an identity only when exactly one author
  claims it (contested identities stay unverified)
- ConcordBrokerToken: the NIP-98-style kind-27235 token request signed by the
  channel's derived voice signer key (its pubkey is the SFU room name), the
  'Authorization: Concord <base64(event)>' header, and the
  /.well-known/concord/av/<room> path

Voice key derivation (voice_signer/voice_media/voice_sender) already lives in
ConcordKeyDerivation. Tests cover presence round-trip, uncontested-only
verification, staleness, and that the broker token is signed by the voice-room
key. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 23:08:07 +00:00
Claude 5669414de7 feat(concord): add invite bundle (33301) and full join flow
Completes the public invite path (CORD-05), pinned to Concord v2 (Armada
invite.ts):

- CommunityInvite: the bundle contents with exact snake_case field names
  (community_id, owner, owner_salt, community_root, root_epoch, channels[],
  relays, name, icon, expires_at, creator_npub, label) + ImagePointer/InviteChannel
- ConcordInviteBundle: build/parse the kind-33301 event (content =
  nip44(CommunityInvite, inviteBundleKey(token)); tags d="",vsk="6"; signed by a
  per-link signer), self-certification validate (owner+salt reproduce
  community_id), expiry check, and mintLink (fresh token + link signer -> bundle
  event + shareable URL)

End-to-end test: create a community, mint an invite link, a stranger parses the
URL, decrypts the bundle with the fragment token, validates the owner
commitment, reconstructs the root, and reads the genesis #general channel.
Wrong-token and forged-owner rejections covered. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 23:04:46 +00:00
Claude f42f587ade feat(concord): add community creation factory and entity coordinates
Completes the "create a community" path (CORD-02 Genesis), pinned to Armada
(concord-v2 control.ts/community.ts):

- ConcordKeyDerivation: control/guestbook plane keys and the keyless entity
  coordinates — grantCoordinate = hkdf32(communityId, "concord/grant"||member),
  banlistCoordinate (ZERO32 id), inviteLinksCoordinate (creator)
- ControlEditionBuilder: assembles kind-3308 edition rumors (vsk/eid/ev/ep/vac),
  the inverse of ControlEdition.fromRumor
- MetadataEntity gains relays
- ConcordCommunityFactory.create: mints owner_salt + self-certifying community_id,
  an independent community_root, and two owner-signed genesis editions (metadata
  with eid=communityId, and a public #general channel) as plaintext-seal wraps on
  the Control Plane at epoch 0

Test creates a community, verifies the id commitment, opens the genesis wraps
(20014 seals, owner-authored), folds them into live ConcordCommunityState with a
#general channel and owner authority, and confirms one owner yields distinct
communities. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 22:53:13 +00:00
Claude b96960db88 feat(concord): add private joined-communities list (kind 13302)
The NIP-51 analog for returning to signed-up Concord communities (CORD-05):

- ConcordCommunityListEntry: per-community credentials needed to re-derive planes
  on any device (id, owner, ownerSalt, current root + rootEpoch, past heldRoots,
  privateChannels keys, relays, cached name)
- ConcordCommunityList: build/parse the replaceable kind-13302 event, NIP-44
  self-encrypted so relays store only ciphertext, plus a cross-device merge that
  keeps the freshest root epoch per community

Channels are intentionally not listed — holding the root and folding the Control
Plane yields them. Tests cover self-encrypted round-trip, that only the owner can
decrypt, and epoch-wins merge. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 22:48:46 +00:00
Claude 7c7608913d fix: never render a group reaction as the Messages row content
The Messages-list feed already filtered kind-9/1068/11/1111 content when
selecting a group's representative note, but the ChatroomEntry render fallback
still rendered ANY group-scoped note — including a kind-7 reaction lingering in
the in-memory list — as the group row, using the reaction's content and time.

Add a shared quartz helper `Event.isGroupChatContent()` and use it in both
places: the feed filter and the render fallback. A non-content group-scoped note
now falls back to the channel placeholder ("No messages yet") instead of showing
the reaction as the room's last message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 22:39:15 +00:00
Claude b0e3594eaa feat(concord): add rekey distribution (CORD-06)
Non-ratcheted async key rotation to remove members from a channel or (root
scope) the whole community, pinned to Concord v2 (Armada rekey.ts):

- RekeyPayload: the 72-byte scope_id||epoch_be8||new_key blob codec
- RekeyBlob: per-recipient {locator, wrapped} entry
- ConcordRekey: blobFor (locator = recipient pseudonym; wrapped = base64 payload
  NIP-44-encrypted under the rotator<->recipient pairwise key), kind-3303 rumor
  tags (scope/newepoch/prevepoch/prevcommit/chunk) and content codec, and
  findNewKey (recipient computes their locator, matches, decrypts, verifies
  scope+epoch) with absence == removal

Test proves remaining members recover the rotated key while a removed member
finds no matching blob, and that the locator is epoch-bound. Green on
:quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 22:20:51 +00:00
Claude b36daf80f6 feat: notify on reactions/replies to my messages in joined groups
Notification events were only fetched from my inbox relays (#p=me), but NIP-29
group activity — a reaction or reply to my message — lives on the group's HOST
relay, so it never surfaced in Notifications until I opened the group (which
subscribes to the group's content directly).

Extend the notifications subscription to also poll each joined group's host relay
for events that tag me, scoped by `#h` to my joined groups (new
filterGroupNotificationsToPubkey over reaction/reply/repost/zap/report kinds).
Re-subscribe when the joined-group list changes so a newly-joined group's relay
is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 22:15:54 +00:00
Vitor PamplonaandGitHub 268a9ff33e Merge pull request #3512 from vitorpamplona/claude/nostr-blocked-relays-review-7m5o3t
Enforce blocked relays centrally via decorator client
2026-07-09 18:12:23 -04:00