diff --git a/amethyst/plans/2026-07-10-concord-mobile-integration.md b/amethyst/plans/2026-07-10-concord-mobile-integration.md new file mode 100644 index 0000000000..6dbc2ece71 --- /dev/null +++ b/amethyst/plans/2026-07-10-concord-mobile-integration.md @@ -0,0 +1,208 @@ +# Concord — Mobile Integration Plan (mirroring NIP-29 Relay Groups) + +## Context + +The Concord protocol engine is complete in `quartz/…/concord/` (CORD-01…07, +~65 tests) and driven end-to-end by the `amy concord` CLI over a commons +`ConcordActions` layer. This plan covers the **Android app integration**, and it +deliberately **mirrors the just-merged NIP-29 relay-groups feature** — that work +used Soapbox's Armada as a study base and established the exact Amethyst touch +points a group-chat protocol should plug into. Wherever possible we clone the +NIP-29 file structure with Concord equivalents rather than inventing parallels. + +Naming: user-facing = **"Concord Channels"** (Amethyst reserves "community" for +NIP-72). Protocol-internal code keeps the spec term `community`. + +## The one structural difference from NIP-29 + +NIP-29 group metadata (kind 39000) is **relay-signed and public**, so groups are +browsable. Concord communities are **end-to-end encrypted**: the only public +artifact is the addressable kind-33301 invite **bundle**, whose content is +token-gated. Consequences for the mirror: + +- **Addressing** is by *derived stream pubkey* (`group_key.pk` per plane/epoch), + not `(hostRelay, groupId)`. A Concord channel lives at its plane address and + may be mirrored on several relays (the community's relay set), not pinned to + one host. So `ConcordChannel.relays()` = the community relay set. +- **Discovery** cannot preview E2EE content. The discovery feed surfaces **public + invite links** (kind-33301 bundles + links shared in notes), filtered by + author/hashtag — the entry action is *redeem a link*, not *browse contents*. + This is a genuinely thinner surface than NIP-29; documented, not a bug. +- **Membership = key possession**, verified locally from the folded Control Plane + + banlist (already implemented), not from relay-signed 39001/39002. + +## Per-account persistence & subscription model (Concord is between NIP-17 and NIP-28/29) + +Separate **addressing** from **encryption/membership** and Concord's place is clear: + +| Concern | NIP-28 | NIP-29 | NIP-17 | **Concord** | +|---|---|---|---|---| +| Find messages by | channel id | `(relay, h)` | `#p = me` | **`authors=[derived plane pk]`** | +| Content | public | public | E2EE to you | **E2EE to a shared key** | +| Decrypt with | — | — | your key | **per-channel derived conv key** | +| Membership | open | relay roster | key possession | **key possession** | +| "My rooms" home | follow list | kind-10009 | chatroom set | **kind-13302 (carries secrets)** | + +The decisive point: a Concord wrap's `p` tag is **ephemeral**, so you can never +find messages with `#p = me` (the NIP-17 model). You subscribe **by author = the +derived plane pubkey** (NIP-28/29 addressing), a query only a secret-holder can +form, and decrypt with the shared plane key (NIP-17 E2EE). + +**Home base = kind-13302 `ConcordCommunityList`** (built in quartz): NIP-44 +self-encrypted, replaceable, relay-synced. Unlike NIP-17 (only secret is your +identity key) or NIP-29 (public group tags), **each entry carries the community +secrets** (`community_root`, salt, epoch, private-channel keys). Same trust model +as NIP-17's recoverable giftwrapped history: a leaked nsec exposes them, nothing +worse. `ConcordChannelListState` wraps 13302 exactly like `RelayGroupListState` +wraps 10009 / `EphemeralChatListState` wraps its list — **same wiring, entries +hold keys.** + +**In-memory projection (LocalCache):** `ConcordChannel` keyed by +`(communityId, channelId)`, holding the folded Control-Plane state + decrypted +messages — recomputed from events, never persisted as identity (the NIP-28/29 +half). + +**Subscription = per-plane author REQ, fanned out from the joined list** — not a +single `#p=me` catch-all. `ConcordMyChannelsFilterAssembler` (mirrors NIP-29's +`RelayGroupMyJoinedGroupsFilterAssembler`) walks `account.concordChannelList`, +derives each community's control-plane + channel-plane addresses, and issues +`{kinds:[1059], authors:[planePk]}` per plane across the community's relays. + +**Secrets at rest:** relay copy is self-NIP-44-encrypted (13302); the on-device +mirror can be wrapped with `commons/keystorage`. + +## Layering (same as NIP-29) + +- `quartz/…/concord/` — protocol (done) +- `commons/…/model/concord/` — `ConcordChannel`, `ConcordChannelListState`, + membership/view-mode enums, discovery constraint (platform-agnostic) +- `amethyst/…/chats/publicChannels/concord/` — screens, feed filters, datasource + subassemblers, navigation +- `commons/…/actions/ConcordActions.kt` — builders/filters/folding (done) +- `cli/…/commands/Concord*Commands.kt` — verbs (done; already matches the + `RelayGroupCommands` route+verb-map pattern) + +## Mirror map (NIP-29 file → Concord equivalent) + +### commons state +- `model/nip29RelayGroups/RelayGroupChannel.kt` → **`model/concord/ConcordChannel.kt`** + — a `Channel` subclass keyed by a `ConcordChannelId(communityId, channelId)`, + holding the folded `ConcordCommunityState` + this channel's messages StateFlow, + `relays()` = community relay set, `membershipOf()` from the authority resolver, + `placeholderNote()`. +- `RelayGroupListState.kt` → **`model/concord/ConcordChannelListState.kt`** — + backed by the **kind-13302** joined-communities list (already in quartz: + `ConcordCommunityList`). Exposes `liveCommunities: StateFlow>` and + `liveServers: StateFlow>`. `join(community)`/`leave` do + read-modify-write of the 13302 event. Mirrors `EphemeralChatListState`. +- `RelayGroupMembership.kt` → **`ConcordMembership.kt`** (OWNER/ADMIN/MEMBER/BANNED/ + NONE) derived from `AuthorityResolver` (rank + banlist). +- `RelayGroupViewMode.kt` → **`ConcordViewMode.kt`** (INLINE/GROUPED). +- `model/nip29RelayGroups/GroupDiscoveryConstraint.kt` → **`ConcordDiscoveryConstraint.kt`** + (AllPublic / ByPeople / ByHashtags) matching against a public invite bundle. + +### Account wiring (`amethyst/…/model/Account.kt`) +Add right after the `relayGroupList` lines (~382): a +`ConcordChannelListState(signer, cache, decryptionCache, scope, settings)` field ++ its decryption cache. Action methods next to `joinRelayGroup` (~1472): +`createConcordCommunity`, `joinConcordFromLink`, `postConcordMessage`, +`createConcordInvite`, `banConcordMember`, `follow/unfollow(ConcordChannel)` → +delegate to `ConcordChannelListState`. Writes go through the community relay set. +Add `concordViewMode` to `AccountSettings.kt`. + +### LocalCache (`amethyst/…/model/LocalCache.kt`) +Add a `LargeCache` index + `getOrCreateConcordChannel`, +and route inbound kind-1059 wraps on known plane addresses into the fold (decrypt +→ edition/message). Mirrors `getOrCreateRelayGroupChannel`. + +### Messages inbox integration (THE key mirror) +- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` + `ChatroomListNewFeedFilter.kt` + — extend the 5-way `feed()` concatenation to **6-way**: add a `concordChannels` + block reading `account.concordChannelList.liveCommunities`, branching on + `concordViewMode` (INLINE = one row per channel via + `LocalCache.getOrCreateConcordChannel(...).newestChatNote() ?: placeholderNote()`; + GROUPED = one synthetic `ConcordServerRoomNote(communityId, newest)` per + community). Update `applyFilter`/`updateListWith` with a + `filterRelevantConcordMessages(...)` keyed by `concordRowKey()`. +- `chats/rooms/dal/RelayGroupServerRoomNote.kt` → **`ConcordServerRoomNote.kt`** — + synthetic event-less Note collapsing a community's channels into one inbox row. +- `chats/rooms/ChatroomHeaderCompose.kt` — add `rendersWithoutEvent` branches for + `ConcordServerRoomNote` and channel placeholders; `ConcordServerRoomCompose` → + `Route.ConcordServer(communityId)`; `ConcordRoomCompose` (chip = community name) + → `routeFor(channel)`. **This is where the "chip opens the Concord Channel" + requirement lands.** + +### Screens (`amethyst/…/chats/publicChannels/concord/`, mirror `relayGroup/`) +- `ConcordServerList.kt` (community rows) · `ConcordChannelListScreen.kt(communityId)` + (a community's channels, from the folded Control Plane) · + `ConcordChatScreen.kt(communityId, channelId, …)` (top-level route target) · + `ConcordChannelView.kt` (reuse the NIP-28 `ChannelFeedViewModel`/`ChannelView` + stack via the `ConcordChannel: Channel` subclass) · `ConcordMembersScreen.kt` · + `ConcordMetadataScreen.kt`/`ViewModel.kt` (create/edit) · `ConcordTopBar.kt` + (name + role badge + Members/Edit/Invite/Ban/Leave menu) · `LoadConcordChannel.kt`. +- Compose composer gated on `membershipOf(me).isMember()`; else a "redeem an + invite to post" notice. + +### Discovery feed (GitRepositories-style triad; thinner than NIP-29) +- `concord/dal/ConcordDiscoveryFeedFilter.kt` (`AdditiveFeedFilter` over + public kind-33301 bundles; "My Communities" branch = the 13302 list) + + `concord/dal/ConcordDiscoveryConstraint.kt` bridge + + `concord/datasource/subassemblies/FilterConcordBundlesBy{Authors,Follows,Hashtag}.kt`. + `ConcordDiscoveryScreen.kt` = `DisappearingScaffold` + `FeedFilterSpinner` + + `RenderFeedContentState` with `ConcordDiscoveryCard` (name + Join button). FAB → + `ConcordBrowse`/redeem-link. + +### Navigation (`ui/navigation/routes/Routes.kt` + `AppNavigation.kt`) +`@Serializable` routes: `Concord`(communityId, channelId, +draftId?/inviteToken?), +`ConcordServer`(communityId), `ConcordMembers`, `ConcordCreate`, `ConcordEdit`, +`Concords`(object, bottom-nav → discovery), `ConcordBrowse`. `RouteMaker.routeFor(ConcordChannel)` ++ deep-link: an invite URL/`nostr:`-embedded link → `Route.Concord(..., inviteToken=…)`, +auto-redeeming on open (mirror NIP-29's inviteCode auto-join). Wire through +`BouncingIntentNav.kt`. + +### Invite/redeem UI + linkification +- `InviteConcordDialog.kt` (moderator: mint + share link via `ConcordActions.mintInviteLink`) + · `JoinConcordDialog.kt` (paste a link → redeem) · `ui/components/ConcordInviteCard.kt` + (render a link as a preview card; tap → `Route.Concord(inviteToken)`) · + `ui/components/ClickableConcordInviteLink.kt` (inline linkify shared invite URLs). + +### Notifications (your explicit ask) +Route a Concord message notification click to the **channel chat**, not the feed: +in the notification builder + `BouncingIntentNav`, map a Concord message +notification to `Route.Concord(communityId, channelId)`. Mirror how NIP-29 +group notifications resolve via `routeFor`. + +### Zaps & likes +Because `ConcordChannel` extends `Channel` and messages render through the shared +`ChannelView`, reactions (kind 7) and zaps attach through the existing chat +reaction/zap path — but they must be **wrapped on the channel plane** (kind-7/9735 +rumors sealed like messages, bound to channel+epoch), not published in the clear. +Add `ConcordActions.buildReaction`/`buildZapRequest` that wrap on the plane, and +point the shared reaction/zap affordances at them for Concord notes. + +## Build order (each a tested, shippable slice) +1. **commons foundation** — `ConcordChannel`, `ConcordChannelListState` (13302), + membership/view-mode enums; unit tests. Wire into `Account.kt` + `AccountSettings`. +2. **LocalCache index** + inbound wrap folding. +3. **Messages inbox** 6-way concat + `ConcordServerRoomNote` + header render/nav + (delivers the chip-opens-channel behavior). +4. **Chat screens** (reuse NIP-28 `ChannelView`) + nav routes + create/invite/join. +5. **Discovery feed** triad (public invite bundles). +6. **Notifications routing + zaps/likes on-plane.** + +## Verification +- commons: `:commons:jvmTest` unit tests for `ConcordChannelListState` (13302 + round-trip/merge) and `ConcordChannel` folding, mirroring + `RelayGroupListDecryptionTest`/`RelayGroupChannelTest`. +- Android: `:amethyst:installDebug`; create a community, see it in Messages with a + chip, tap → channel opens, send/receive between two emulators, redeem an invite + link deep-link, verify a notification click opens the chat. Cross-check against + `amy concord` (same relay) for wire interop, and against Armada for protocol + interop (`Nip29ArmadaInteropTest` is the precedent). + +## Gotchas carried from the NIP-29 study +- Membership has two independent layers (Concord authority vs NIP-43 relay + membership); we only implement Concord authority. +- Cache-as-floor + optimistic local signing for snappy UX. +- E2EE means no server-side moderation and no metadata preview — surface state + from the local fold only. diff --git a/amethyst/plans/2026-07-12-dual-reply-minichat.md b/amethyst/plans/2026-07-12-dual-reply-minichat.md new file mode 100644 index 0000000000..7416c2becd --- /dev/null +++ b/amethyst/plans/2026-07-12-dual-reply-minichat.md @@ -0,0 +1,113 @@ +# Dual-mode replies: inline + "minichat" threads across all chats + +## Goal + +Give every Amethyst chat two ways to reply, chosen at send time: + +- **Inline reply** — a normal chat message that references its parent and stays in + the main timeline (today's behavior). On the wire this is the chat protocol's + native reply: NIP-C7 kind-9 with a `q` quote (Concord), kind-42 reply (NIP-28), + kind-9 `+h` reply (NIP-29), kind-14 reply (NIP-17 DM). +- **Minichat reply** — a **kind-1111 NIP-22 `CommentEvent`** rooted at the parent + message. It is pulled *out* of the main timeline and shown in a separate + **minichat** ("chat within a chat") opened from the parent. This matches Soapbox + Armada exactly (kind-9 `q` = inline quote, kind-1111 = thread). + +The rule is uniform and protocol-agnostic: **any kind-1111 whose root is a chat +message opens as that message's minichat.** So the same treatment automatically +covers Concord kind-9, NIP-28 kind-42, NIP-29 kind-9, and (later) NIP-17 kind-14 — +wherever a 1111 lands on a chat message. + +## Reuse survey (what already exists — do NOT rebuild) + +| Need | Reuse | +|---|---| +| kind-1111 reply builder (NIP-22 `K/E/P`+`k/e/p`) | `quartz/.../nip22Comments/CommentEvent.replyBuilder`; Concord's `ChannelChat.reply` already uses it | +| 1111 → parent wiring | `LocalCache.computeReplyTo` (CommentEvent branch) → `parentNote.replies`; minichat content = `note.replies.filter { it.event is CommentEvent }` | +| "N replies" chip | `observeNoteReplyCount(note, avm)` (EventObservers.kt) — already used by `RelayGroupThreadsScreen` | +| Shared per-row action strip | `ChatMessageCompose.NormalChatNote` `detailRow` `Row` — one place, every chat type | +| Thread rendering | `threadview/ThreadFeedView` + `ThreadAssembler.findThreadFor`; NIP-29 `RelayGroupThreadsScreen` as the chat-adjacent precedent | +| Per-message 1111 REQ (public chats) | `FilterRepliesAndReactionsToNotes` (kinds incl 1111, `#e`) via `EventFinder`; `RelayGroupThreadFeedFilterAssembler` (compose-scoped `#h`+1111 sub) | +| Composer reply state + "replying-to" preview | `*NewMessageViewModel.replyTo` + `chats/utils/DisplayReplyingToNote` | +| NIP-22 comment composer | `note/nip22Comments/CommentPostViewModel` (full-featured) | + +Concord already delivers kind-1111 replies through the existing channel-plane +subscription (they're wrapped like every other rumor), so **no new subscription is +needed for Concord** — only the timeline split, the chip, the minichat screen, and +the composer picker. + +## Design + +### 1. Wire model (settled — matches Armada) +- Inline reply → native chat reply event, native reply tags, stays in timeline. +- Minichat reply → kind-1111 `CommentEvent`: uppercase `K/E/P` at the immutable + thread root (the chat message), lowercase `k/e/p` at the immediate parent, plus + whatever binding the plane requires (Concord: `channel`/`epoch`). One level: + replying inside a minichat roots the new 1111 at the **same** root message + (parent = the message being answered, root = the minichat root), rendered flat — + so minichat messages don't spawn sub-threads. (The wire still permits nesting; + we render flat.) + +### 2. Timeline vs minichat split (rendering) +- **Main feed** excludes kind-1111 comments whose root is a chat message — they + live in the minichat, not as flat siblings. Implemented in the shared + `ChannelFeedFilter` / `ChatroomFeedFilter` by dropping `CommentEvent`s that root + onto a message already in the feed (keep everything else). +- Each root message row shows an **"N replies" chip** (from `observeNoteReplyCount` + restricted to CommentEvent replies) in the `detailRow` strip; tap → minichat route. + +### 3. Minichat screen +- A thread screen keyed by the **root message id** (+ the channel/room key needed to + re-derive the plane / re-subscribe). Renders the root message pinned at top, then + its kind-1111 replies as a flat mini-timeline (reuse `ChatroomMessageCompose`), with + its own composer that always sends kind-1111 rooted at this message. +- Back it with `ThreadFeedView`/`ThreadAssembler` where possible; for Concord, feed + it from `rootNote.replies` (already populated) + a lifecycle sub that keeps the + plane live. + +### 4. Composer mode picker +- Add `replyMode: ReplyMode {INLINE, MINICHAT}` next to `replyTo` in each + `*NewMessageViewModel` (Concord `ConcordNewMessageViewModel`, DM + `ChatNewMessageViewModel`, channels `ChannelNewMessageViewModel`). +- Render a small toggle beside `DisplayReplyingToNote` ("Reply in chat" ⇄ "Reply in + thread"). Default = INLINE (least surprise; user opts into pulling it aside). +- Send branch: `MINICHAT` routes to the kind-1111 builder + (`CommentEvent.replyBuilder` / Concord `buildChannelReply`), `INLINE` keeps the + native reply builder. + +### 5. Subscriptions +- **Concord**: none new (1111 arrives via the channel plane). Just ensure the + timeline filter and minichat read `rootNote.replies`. +- **NIP-28 / NIP-29 (phase 2)**: add a compose-scoped assembler (clone + `RelayGroupThreadFeedFilterAssembler`) that REQs `{kinds:[1111], "#e":[]}` (and `#E`) off the feed's current message-id set (from + `FeedContentState`). Reuse the same minichat screen/row. +- **NIP-17 DM (phase 3, later)**: kind-1111 replies must be gift-wrapped like the + kind-14s; deferred — needs an encrypted-comment path, more design. + +## Phasing + +1. **Phase 1 — Concord, full UX + all shared pieces.** ReplyMode enum + composer + toggle; timeline split (drop chat-rooted 1111s); "N replies" chip in the shared + `detailRow`; minichat route + screen; Concord send branch. Delivers the complete + dual-mode experience for Concord and builds every shared component. +2. **Phase 2 — public chats.** Per-message 1111 subscription for NIP-28 + NIP-29; + reuse the Phase-1 chip/screen/composer. NIP-29 already has a thread screen to + reconcile with. +3. **Phase 3 — DMs.** Gift-wrapped kind-1111 minichat for NIP-17. Deferred. + +## Decisions (settled) +- **Default mode** when tapping reply: **INLINE**. User opts into MINICHAT via the toggle. +- **Minichat depth**: **flat, one level**. Replying inside a minichat roots at the + same message; no sub-threads. +- **Scope now**: **Phase 1 + 2 together** — Concord AND public chats (NIP-28/NIP-29). + DMs (phase 3) still deferred. +- **Screen styling**: **chat-styled bubbles** (reuse `ChatroomMessageCompose`) so the + minichat reads as "a chat within a chat". + +## Verification +- quartz/commons unit tests for the reply-mode builders + the timeline-filter split + (a chat-rooted 1111 is excluded from the feed but present in `rootNote.replies`). +- On-device: in Concord, reply inline (stays in timeline) and reply-in-thread (opens + minichat); confirm Armada shows our minichat replies as a thread and its threads + open as our minichat; confirm the "N replies" chip count. diff --git a/amethyst/plans/2026-07-13-cord06-refounding.md b/amethyst/plans/2026-07-13-cord06-refounding.md new file mode 100644 index 0000000000..a74e8ffd53 --- /dev/null +++ b/amethyst/plans/2026-07-13-cord06-refounding.md @@ -0,0 +1,83 @@ +# CORD-06 Refounding — real member removal for Concord + +## Problem + +Concord membership is key possession: a banned member (CORD-04 banlist) still +holds the community's `community_root`, so every client just *declines to show* +their posts — they can still decrypt everything. That is a soft removal. CORD-06 +adds the hard removal: rotate the key so a removed member's key stops working for +anything sent afterwards. + +The quartz crypto for the kind-3303 rekey blob (`ConcordRekey`, `RekeyBlob`) +already existed and was tested, but nothing in the app called it. This wires the +whole path — build, publish, receive, persist, UI — around a **Refounding** +(whole-community rotation), the removal that matters while Amethyst supports only +public channels (a per-channel rekey needs private channels, not built yet). + +## What a Refounding does (CORD-06 §3) + +1. Ban the removed members on the current Control Plane (so the compacted snapshot + carries the ban). +2. Roll `community_root` to a fresh random 32 bytes at `rootEpoch + 1`. Public + channels + the Control/Guestbook planes all derive from the root, so rolling it + rotates every plane at once. +3. Republish the **compacted** Control Plane under the new root — keep only each + entity's head edition and re-wrap its *original plaintext seal*, so the original + authors' signatures survive re-encryption (a fresh joiner verifies the slim + state exactly as it verified the full chain). +4. Mint per-recipient kind-3303 rekey blobs delivering the new root to every + retained member, sealed + addressed under the **prior** root on the + `base-rekey-pseudonym(prior_root, community_id, new_epoch)` address — which every + current member precomputes, so they receive it live. A removed member gets no + blob and can never derive the new root. + +## Layers + +- **quartz** `concord/cord06Rekey/` + - `ConcordKeyDerivation`: `baseRekeyAddress` / `channelRekeyAddress` (the rekey + stream addresses), `epochKeyCommitment` (`prevcommit`, CORD-02 §A.5). + - `ConcordRekey`: signer-based `blobForSigner` / `findNewKeyWithSigner` (bunker + accounts open a blob with one `nip44Decrypt`, no raw key). + - `ConcordRefounding`: `compactControlPlane`, `buildBaseRekeyWraps`, `build` + (whole refounding), `findNewRoot` (receive: verify scope/epoch/continuity, find + my blob). `OpenedStreamEvent` now also carries the inner `seal` so compaction + can re-wrap it. Tests in `ConcordRefoundingTest`. +- **commons** + - `ConcordActions`: `guestbookPlane` / `nextBaseRekeyPlane`, `buildGuestbookJoin` + / `guestbookMembers`, `buildRefounding`, `openBaseRekey`. + - `ConcordCommunitySession`: folds the Guestbook plane into `members` + (the recipient set), buffers inbound base-rekey wraps (`pendingBaseRekeyWraps`), + exposes `controlPlaneWraps` for compaction, and AUTHs to + subscribes the + Guestbook and next-epoch base-rekey planes (`streamKeys`, `subscribeAddresses`). + - `ConcordSessionRegistry.sync`: rebuilds a session when its entry's root/epoch + changed — the session is a pure function of its entry, so adopting a new root is + just a persisted entry swap. + - `ConcordSubscriptionPlanner.auxiliaryPlaneSubs`: REQs the Guestbook + next + base-rekey planes for every joined community. +- **amethyst** + - `Account`: announces a Guestbook JOIN on create/join (`announceConcordGuestbookJoin`) + so members are visible to a future rotator; `refoundConcordCommunity` (owner / + BAN-holder) bans + rolls + publishes + persists; `drainConcordRekeys` (revision + tick) adopts an inbound rotation from an authorized rotator; `adoptConcordRoot` + persists the new root (prior root kept as a `HeldRoot`) and re-seeds the new + epoch's Guestbook, guarded against double-adopt. + - `AccountViewModel.removeConcordMember`; `ConcordMembersScreen` "Remove from + community" action + confirm dialog, gated exactly like Ban. + +## Recipient set + +The rotator re-keys **Guestbook membership ∪ the privileged roster ∪ self**, minus +the removed and the already-banned. The Guestbook is best-effort/off-consensus, so +a member who joined but whose Guestbook JOIN hasn't propagated to the rotator would +be missed and locked out — the accepted trade for a serverless, key-possession +membership model. Adopting a new root re-announces the Guestbook JOIN at the new +epoch so cascading removals keep a live membership. + +## Known limitations / follow-ups + +- No explicit "you were removed" detection: a removed member simply stops receiving + new content (their old-epoch keys still read history). CORD-06's "held all n + chunks, none is mine ⇒ removed" self-eviction is not implemented. +- Per-channel rekey (single private channel) is not wired — needs private channels. +- Race convergence (two rotators, same epoch, lexicographically-lowest-key wins) is + not implemented; single-rotator (owner/admin) refounding is the supported path. diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt index bb4feac936..642062322b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -97,7 +97,7 @@ class EventSyncTest { RelayAuthenticator( newClient, appScope, - signWithAllLoggedInUsers = { authTemplate -> + signWithAllLoggedInUsers = { _, authTemplate, _ -> listOf(signer.sign(authTemplate)) }, ) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index e8035be81f..5a7960c004 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -193,6 +193,16 @@ + + + + + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index aa759eed27..b437a6e838 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -26,6 +26,7 @@ import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm @@ -162,6 +163,7 @@ private object PrefKeys { const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode" + const val CONCORD_VIEW_MODE = "concord_view_mode" const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues" const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows" const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows" @@ -521,6 +523,7 @@ object LocalPreferences { putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name) + putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name) putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value) putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value) putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value) @@ -646,6 +649,7 @@ object LocalPreferences { ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } ?: RelayAuthPolicy.CUSTOM val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)) + val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)) val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true) val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true) val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true) @@ -859,6 +863,7 @@ object LocalPreferences { alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), relayGroupViewMode = MutableStateFlow(relayGroupViewMode), + concordViewMode = MutableStateFlow(concordViewMode), relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays), relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows), relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 25e869c8f9..25ba607e21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -21,11 +21,17 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState +import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState @@ -61,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache @@ -138,6 +145,18 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -168,6 +187,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -175,9 +196,11 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -219,6 +242,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag @@ -309,6 +333,7 @@ import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -319,6 +344,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -330,6 +356,9 @@ import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as gallery private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" +/** Name of the default Concord community Admin role minted by "Make admin". */ +private const val CONCORD_ADMIN_ROLE = "Admin" + @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -441,6 +470,89 @@ class Account( val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer) val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings) + val concordChannelList = ConcordChannelListState(signer, cache, scope, settings) + + /** + * The live read-path for joined Concord Channels: one folding session per + * community, fed by inbound kind-1059 plane wraps. Kept in step with + * [concordChannelList] and consulted by the giftwrap decrypt path so a Concord + * plane wrap routes here instead of being dropped as an undecryptable DM. + */ + val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope, ::consumeConcordRumorGated) + + /** + * Sink for decrypted Concord rumors: drops a message whose author is banned in + * the community's current fold before it ever becomes a Note, then delegates to + * the cache. Bans that arrive *after* a message are handled by removing the + * author's existing notes on re-fold (see `refreshConcordChannelIndex`); this + * gate stops *new* posts from a banned author from appearing at all. + */ + private fun consumeConcordRumorGated( + communityId: String, + channelIdHex: String, + rumor: Event, + ) { + val authority = + concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority + if (authority?.isBanned(rumor.pubKey) == true) return + registerConcordEncryptedImages(rumor) + cache.consumeConcordRumor(communityId, channelIdHex, rumor) + } + + /** + * Register any encrypted image attachments on a Concord message ([ChannelChat.encryptedImagesOf]) + * so the shared media pipeline can display them: the ciphertext blob's AES-256-GCM key/nonce go + * into [com.vitorpamplona.amethyst.AppModules.keyCache], and the OkHttp EncryptedBlobInterceptor + * decrypts the blob transparently on fetch (keyed by URL) — the same path NIP-17 encrypted media + * uses. Runs for both inbound wraps and our own local echo, so a sent image renders immediately. + */ + private fun registerConcordEncryptedImages(rumor: Event) { + val images = ChannelChat.encryptedImagesOf(rumor) + if (images.isEmpty()) return + val keyCache = Amethyst.instance.keyCache + images.forEach { img -> + if (img.algo == AESGCM.NAME) { + keyCache.add(img.url, AESGCM(img.key, img.nonce), img.mimeType) + } + } + } + + /** + * Copies each folded community's metadata (name/icon, channel flags, this account's + * membership) onto its [ConcordChannel] objects in the cache, and drops messages from + * authors banned since they loaded. Runs account-wide on every + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager] revision — + * NOT gated behind the Concord hub screen — so every surface (the Messages-tab + * community chip, the chat screen title) reflects the current fold, and bans apply, + * even when the hub was never opened. + */ + fun refreshConcordChannelIndex() { + val myPubKey = signer.pubKey + val relaysByCommunity = + concordChannelList.liveCommunities.value.associate { entry -> + entry.id to entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + } + for (session in concordSessions.sessions()) { + val state = session.state.value ?: continue + val communityId = session.entry.id + val relays = relaysByCommunity[communityId] ?: emptySet() + for (channelIdHex in state.channels.keys) { + val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) + // Invalidate the channel's metadata flow only on a real change so the Messages-row + // name + community chip recompose when the fold first resolves them (they observe + // metadata.stateFlow via observeChannel), without churning every row every tick. + if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo() + channel.notes + .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } + .forEach { channel.removeNote(it) } + } + } + } + val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) @@ -1815,6 +1927,709 @@ class Account( suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel)) + /** + * Add a joined Concord community (secret-bearing entry) to the private kind-13302 + * list, and announce a self-signed Guestbook JOIN so this member is visible to + * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). + */ + suspend fun joinConcordCommunity( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ) { + sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) + announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) + } + + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ + private suspend fun announceConcordGuestbookJoin( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey?, + inviteLabel: String?, + ) { + if (!isWriteable()) return + val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildGuestbookJoin(signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) + concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) client.publish(wrap, relays) + } + + /** + * Create a new Concord community: mint its genesis (metadata + #general), + * publish the owner-signed genesis wraps to [relays] (or our outbox), and add + * the secret-bearing entry to the kind-13302 joined list. Returns the new + * community id, or null if not writeable. + */ + suspend fun createConcordCommunity( + name: String, + description: String? = null, + relays: List = emptyList(), + icon: ImagePointer? = null, + ): String? { + if (!isWriteable()) return null + val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } + val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls, icon) + + val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } + community.genesisWraps.forEach { client.publish(it, publishTo) } + + joinConcordCommunity( + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = relayUrls, + name = name, + addedAt = TimeUtils.now() * 1000, + ), + ) + return community.communityIdHex + } + + /** + * Mint a shareable invite link for a joined community and publish its + * kind-33301 public bundle to the community relays. Returns the `…/invite/…` + * URL, or null if the community isn't joined or isn't writeable. + */ + suspend fun mintConcordInvite( + communityId: String, + base: String = "https://amethyst.social", + ): String? { + if (!isWriteable()) return null + val entry = concordChannelList.liveCommunities.value.firstOrNull { it.id == communityId } ?: return null + val invite = + ConcordActions.inviteFor( + communityIdHex = entry.id, + ownerPubKey = entry.owner, + ownerSaltHex = entry.ownerSalt, + communityRootHex = entry.root, + rootEpoch = entry.rootEpoch, + name = entry.name, + relays = entry.relays, + ) + val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) + + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } + if (publishTo.isNotEmpty()) client.publish(minted.bundleEvent, publishTo) + return minted.url + } + + /** Drop a joined Concord community from the private kind-13302 list by its id. */ + suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId)) + + /** + * Redeem a Concord invite link (`…/invite/#`): parse it, fetch + * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it + * with the fragment token, and add the resulting secret-bearing entry to the + * kind-13302 joined list. Returns the joined community id, or null if the link + * is invalid, unreadable, or no valid bundle is found. + */ + suspend fun joinConcordViaInvite(url: String): String? { + if (!isWriteable()) return null + val parsed = ConcordActions.parseInviteLink(url) ?: return null + + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return null + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = client.fetchAll(filters = filters) + val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } ?: return null + + val entry = + ConcordCommunityListEntry( + id = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + relays = bundle.relays, + name = bundle.name, + addedAt = TimeUtils.now() * 1000, + ) + joinConcordCommunity(entry) + return bundle.communityId + } + + /** + * Post [text] to a Concord channel: derive the channel plane key, build an + * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), + * fold it locally for an instant echo, and publish it to the community's relays. + * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes + * straight to the community relay set. Returns false if not writeable or the + * community isn't currently joined/folded. + */ + suspend fun sendConcordChannelMessage( + communityId: String, + channelIdHex: String, + text: String, + replyTo: Note? = null, + replyMode: ReplyMode = ReplyMode.INLINE, + ): Boolean { + if (!isWriteable()) return false + val session = concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + + // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the + // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). + val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() + + val parent = replyTo?.event + val wrap = + when { + // A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9 + // message quoting the parent; a fresh post is a plain kind-9 message. + parent != null && replyMode == ReplyMode.MINICHAT -> + ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + parent != null -> + ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + else -> + ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) + } + publishConcordWrap(entry, wrap) + return true + } + + /** + * Send a channel message carrying encrypted image attachments ([imetas], built by the composer + * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are + * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no + * attachments this is just a plain [sendConcordChannelMessage]. + */ + suspend fun sendConcordChannelImageMessage( + communityId: String, + channelIdHex: String, + text: String, + imetas: List, + ): Boolean { + if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) + if (!isWriteable()) return false + val session = concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. + val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() + val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that + * message. Resolves the chat context from the note's gatherer; today it drives the + * Concord channel path (NIP-28/NIP-29 public-chat minichats are a follow-up). Returns + * false if the message isn't in a chat we can post a thread reply to. + */ + suspend fun sendMinichatReply( + rootNote: Note, + text: String, + ): Boolean { + if (!isWriteable()) return false + val gatherers = rootNote.inGatherers + + gatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { concord -> + return sendConcordChannelMessage( + concord.channelId.communityId, + concord.channelId.channelId, + text, + rootNote, + ReplyMode.MINICHAT, + ) + } + + // Public chats: a plain public kind-1111 comment rooted at the message. NIP-29 groups + // additionally carry the `h` tag and go only to the host relay. + val rootEvent = rootNote.event ?: return false + + gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat -> + val relays = chat.relays() + val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull()))) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, relays.ifEmpty { outboxRelays.flow.value }) + return true + } + + gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.let { group -> + val hostRelay = group.groupId.relayUrl + val signed = + signer.sign( + CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { + hTag(group.groupId.id) + }, + ) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(hostRelay)) + return true + } + + return false + } + + /** + * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors + * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's + * channel/epoch, wraps it on the plane, and publishes it — so the reaction stays + * inside the encrypted channel (never a plaintext public kind-7 that would leak + * the message id). [note] must be a Concord channel message (carries a + * [ConcordChannel] gatherer). + */ + suspend fun reactToConcordMessage( + note: Note, + reaction: String, + ): Boolean { + if (!isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to + // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. + val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray() + val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at + * most every few seconds while composing. Not folded locally (we never show our own typing); + * ephemeral, so relays broadcast but never store it. + */ + suspend fun sendConcordTyping( + communityId: String, + channelIdHex: String, + ) { + if (!isWriteable()) return + val entry = concordSessions.sessionFor(communityId)?.entry ?: return + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) client.publish(wrap, relays) + } + + /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ + private fun publishConcordWrap( + entry: ConcordCommunityListEntry, + wrap: Event, + ) { + concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) client.publish(wrap, relays) + } + + // ── Concord roles & moderation (CORD-04) ───────────────────────────────── + // Each publishes a Control Plane edition; authority is enforced at fold time by + // every client's AuthorityResolver, so a call by someone who doesn't outrank the + // target is simply dropped on fold. Owner-authored calls always take effect. + + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ + suspend fun grantConcordRole( + communityId: String, + member: HexKey, + roleIds: List, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** The default community Admin role: position 1, holding every management + moderation permission. */ + private fun concordAdminRole() = + RoleEntity( + name = CONCORD_ADMIN_ROLE, + position = 1, + permissions = + ConcordPermissions + .of( + ConcordPermissions.MANAGE_ROLES, + ConcordPermissions.MANAGE_CHANNELS, + ConcordPermissions.MANAGE_METADATA, + ConcordPermissions.KICK, + ConcordPermissions.BAN, + ConcordPermissions.MANAGE_MESSAGES, + ConcordPermissions.CREATE_INVITE, + ).toWire(), + ) + + /** + * If [note] is a Concord channel message whose author the OWNER may toggle + * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner + * qualifies — the Admin role sits at position 1 and the resolver requires the + * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for + * the owner's own note, the owner as target, or a non-owner actor. + */ + fun concordAdminTarget(note: Note): Triple? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == signer.pubKey) return null + val communityId = channel.channelId.communityId + val state = concordSessions.sessionFor(communityId)?.state?.value ?: return null + if (state.authority.isOwner(author) || !state.authority.isOwner(signer.pubKey)) return null + val adminRoleId = + state.roles.entries + .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + ?.key + val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) + return Triple(communityId, author, isAdmin) + } + + /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ + suspend fun makeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val cp = session.controlPlaneKey() + + val existing = + session.state.value + ?.roles + ?.entries + ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + val roleIdHex = + existing?.key ?: run { + val roleId = RandomInstance.bytes(32) + val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, roleWrap) + roleId.toHexKey() + } + + val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** Revoke all roles from [member] (demote an admin back to a plain member). */ + suspend fun removeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** + * If [note] is a Concord channel message whose author this account is allowed to + * ban — the actor is the owner or holds the BAN permission, and the target is + * neither the owner nor the actor — returns `(communityId, memberHex)`. Null + * otherwise, so the UI shows the Ban action only when it would actually take + * effect on fold. + */ + fun concordBanTarget(note: Note): Pair? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == signer.pubKey) return null + val communityId = channel.channelId.communityId + val authority = + concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority ?: return null + if (authority.isOwner(author)) return null + val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) + return if (canBan) communityId to author else null + } + + /** Add [member] to the community banlist. */ + suspend fun banConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Remove [member] from the community banlist. */ + suspend fun unbanConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── + // A ban is a soft removal — the banned member still holds the room key and can + // still decrypt traffic; every client just declines to *show* their posts. A + // Refounding is the hard removal: it rotates the community_root, so a removed + // member's key stops working for anything published afterwards. + + /** + * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, + * roll the `community_root`, re-key every retained member (Guestbook membership ∪ + * the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted + * Control Plane under the new root. A removed member keeps the prior root (so + * their history stays readable) but receives no blob, so they can never decrypt + * anything published after the rotation. + * + * Requires ownership or the BAN permission; returns false otherwise (or if the + * community isn't joined/writeable, or a target is the owner). + */ + suspend fun refoundConcordCommunity( + communityId: String, + removed: Set, + ): Boolean { + if (!isWriteable()) return false + val session = concordSessions.sessionFor(communityId) ?: return false + val state = session.state.value ?: return false + val authority = state.authority + val iCanBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) + if (!iCanBan) return false + val removedLower = removed.mapTo(HashSet()) { it.lowercase() } + if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + + // 1. Ban the removed members on the current Control Plane so the compacted snapshot — + // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally + // first, so each subsequent edition chains onto the updated banlist head. + for (target in removedLower) { + val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, banWrap) + } + + // 2. Recipient set: everyone we're keeping — Guestbook joins ∪ roster ∪ self, minus the + // removed and the already-banned. + val recipients = + (session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removedLower) + removeAll(authority.bannedMembers()) + }.toList() + + // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. + val entry = session.entry + val newRoot = RandomInstance.bytes(32) + val build = + ConcordActions.buildRefounding( + rotatorSigner = signer, + communityId = communityId, + priorRoot = entry.root.hexToByteArray(), + newRoot = newRoot, + rootEpoch = entry.rootEpoch, + priorControlWraps = session.controlPlaneWraps(), + priorControlKey = session.controlPlaneKey(), + recipientsXOnly = recipients, + createdAt = TimeUtils.now(), + ) + + // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs + // (the key that unlocks it) to the community relays. + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isNotEmpty()) { + build.controlWraps.forEach { client.publish(it, publishTo) } + build.rekeyWraps.forEach { client.publish(it, publishTo) } + } + + // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and + // re-folds the compacted Control Plane (with the ban), dropping the removed members. + adoptConcordRoot(entry, newRoot, build.newEpoch) + return true + } + + // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered + // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not + // adopted — and re-published — twice on successive revision ticks. + private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) + + /** + * Persist a rotated access root/epoch for [entry], keeping the prior root as a + * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the + * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — + * without this, cascading removals would lose everyone but the roster). No-op if + * this exact rotation was already adopted. + */ + private suspend fun adoptConcordRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + ) { + if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + val next = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = held, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + ) + sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) + announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) + } + + /** + * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for + * each joined community, look for our new root among the kind-3303 wraps seen at + * our next base-rekey address. If a role-authorized rotator (owner or a current + * BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session + * rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap + * never re-triggers. Called on every Concord revision tick. + */ + private suspend fun drainConcordRekeys() { + if (!isWriteable()) return + for (session in concordSessions.sessions()) { + val wraps = session.pendingBaseRekeyWraps() + if (wraps.isEmpty()) continue + val entry = session.entry + val received = + ConcordActions.openBaseRekey( + wraps = wraps, + baseRekey = session.nextBaseRekeyKey(), + recipientSigner = signer, + priorRoot = entry.root.hexToByteArray(), + rootEpoch = entry.rootEpoch, + ) ?: continue + if (received.newEpoch <= entry.rootEpoch) continue + val authority = session.state.value?.authority ?: continue + val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN) + if (!authorized) continue + adoptConcordRoot(entry, received.newRoot, received.newEpoch) + } + } + + /** + * Replace the community metadata (name / icon / description / relays) with a new + * Control-Plane edition. Honored on fold only when this account holds + * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. + */ + suspend fun editConcordMetadata( + communityId: String, + name: String, + description: String?, + icon: ImagePointer?, + banner: ImagePointer?, + relays: List, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) + val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold + * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on + * the same predicate. The channel id is a fresh random 32-byte entity id. + */ + suspend fun createConcordChannel( + communityId: String, + name: String, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val channelId = RandomInstance.bytes(32) + val channel = ChannelEntity(name = name.trim()) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ + suspend fun renameConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val channel = ChannelEntity(name = name.trim()) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ + suspend fun deleteConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val channel = ChannelEntity(name = name.trim(), deleted = true) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from + * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT + * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a + * card can show what the link opens, or null if the link is invalid/unreadable. + */ + suspend fun peekConcordInvite(url: String): CommunityInvite? { + val parsed = ConcordActions.parseInviteLink(url) ?: return null + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return null + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = client.fetchAll(filters = filters) + return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + } + + /** + * Bootstrap the Concord hub from the network: fetch this account's kind-13302 + * joined-communities list and fold the newest into [LocalCache], so communities + * we joined on another Concord client with this key surface here. + * + * We query a wide relay set because different Concord clients publish this + * private list to different places: the reference clients (Armada/Vector) push + * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may + * also have copied it onto their **own** outbox/read relays. Our normal account + * subscription never asks for kind 13302, so without this explicit fetch a + * community joined on Armada would never appear — even if the list sits on the + * user's own outbox. + * + * Read-only import: kind 13302 is replaceable, so folding an older copy is a + * no-op and this is safe to call on every hub open. Merging our own edits with + * a foreign writer's is a separate concern (newest-wins replaceable). + */ + suspend fun importConcordCommunities() { + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return + val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) + // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give + // the fetch a generous window to drain every relay before we pick the newest copy. + val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) + val newest = events.filterIsInstance().maxByOrNull { it.createdAt } + val entryCount = newest?.let { runCatching { it.decrypt(signer).size }.getOrElse { -1 } } ?: 0 + Log.d( + "Concord", + "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + + "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", + ) + newest?.let { cache.justConsumeMyOwnEvent(it) } + } + // ── NIP-29 relay-group actions ─────────────────────────────────────────── // All group commands are published ONLY to the group's host relay, where // relay29 authorizes them. The relay is the source of truth; the kind-10009 @@ -4005,7 +4820,27 @@ class Account( return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true } + /** + * True if [note] is a Concord channel message whose author is banned in that + * community's current fold. Bans are per-community (not global mutes), so they + * are enforced here at read time — the same "filter, don't delete" approach the + * rest of the app uses. A ban that arrives after a message is applied on the + * next feed pass. + */ + private fun isConcordBanned(note: Note): Boolean { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return false + val authority = + concordSessions + .sessionFor(channel.channelId.communityId) + ?.state + ?.value + ?.authority ?: return false + return authority.isBanned(author) + } + override fun isAcceptable(note: Note): Boolean { + if (isConcordBanned(note)) return false val mutedThreads = hiddenUsers.flow.value.mutedThreads if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false return note.author?.let { isAcceptable(it) } ?: true && @@ -4321,6 +5156,20 @@ class Account( } } + // Keep Concord channel metadata (community name/icon, membership) live across the whole + // app — not just the hub screen — so the Messages tab renders each channel's community + // chip, and per-community bans apply, as soon as a Control Plane folds. The revision now + // bumps only on *structural* change (a fold / membership / rekey, never a plain message), + // so this fires rarely; sample() stays as a cheap coalescer for a burst of folds. + scope.launch { + @OptIn(kotlinx.coroutines.FlowPreview::class) + concordSessions.revision.sample(500).collect { + refreshConcordChannelIndex() + // A revision also bumps when a base-rotation rekey lands; adopt ours if present. + runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + } + } + scope.launch { cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index e2ac27901e..70f5e57625 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository @@ -36,6 +38,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -248,6 +251,7 @@ class AccountSettings( var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupRelayGroupList: SimpleGroupListEvent? = null, + var backupConcordList: ConcordCommunityListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, var backupCashuWallet: CashuWalletEvent? = null, var backupNutzapInfo: NutzapInfoEvent? = null, @@ -276,6 +280,7 @@ class AccountSettings( val callsEnabled: MutableStateFlow = MutableStateFlow(true), val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.CUSTOM), val relayGroupViewMode: MutableStateFlow = MutableStateFlow(RelayGroupViewMode.DEFAULT), + val concordViewMode: MutableStateFlow = MutableStateFlow(ConcordViewMode.DEFAULT), // The per-situation toggles applied under RelayAuthPolicy.CUSTOM. val relayAuthTrustMyRelaysAndVenues: MutableStateFlow = MutableStateFlow(true), val relayAuthTrustReadFollows: MutableStateFlow = MutableStateFlow(true), @@ -283,6 +288,7 @@ class AccountSettings( val relayAuthTrustMessageStrangers: MutableStateFlow = MutableStateFlow(false), ) : EphemeralChatRepository, RelayGroupRepository, + ConcordListRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal()) @@ -304,6 +310,13 @@ class AccountSettings( } } + fun updateConcordViewMode(mode: ConcordViewMode) { + if (concordViewMode.value != mode) { + concordViewMode.tryEmit(mode) + saveAccountSettings() + } + } + // --- // Always-on Notification Service // --- @@ -1277,6 +1290,19 @@ class AccountSettings( } } + override fun concordList() = backupConcordList + + override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) { + // The joined list lives entirely in NIP-44-encrypted content (secrets), + // so an empty `tags` is NOT an empty list — guard only on null. + if (newConcordList == null) return + + if (backupConcordList?.id != newConcordList.id) { + backupConcordList = newConcordList + saveAccountSettings() + } + } + fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) { if (trustProviderList == null || trustProviderList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index e311fb35ac..124d16e598 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -48,6 +49,8 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -355,6 +358,7 @@ object LocalCache : ILocalCache, ICacheProvider { val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() val relayGroupChannels = LargeCache() + val concordChannels = LargeCache() val paymentTracker = NwcPaymentTracker() @@ -723,6 +727,62 @@ object LocalCache : ILocalCache, ICacheProvider { fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) } + fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key) + + fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) } + + /** + * Lands a decrypted Concord chat rumor in the cache as a real Note and, for + * message-like kinds, attaches it to its channel so the shared chat feed and + * the Messages inbox render it (with previews, threading, OTS, reactions/zaps + * reusing the same id-keyed machinery as every other chat). Reactions (kind 7), + * deletes (kind 5), etc. are consumed too — they wire to their target Note by + * `e`-tag through [justConsume] — but are not themselves added as channel rows. + * + * Fed by [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager] + * once a wrap decrypts + validates against the folded Control Plane. + */ + fun consumeConcordRumor( + communityId: String, + channelIdHex: String, + rumor: Event, + ) { + // Attach to the channel BEFORE justConsume sets the event and notifies feeds, + // so the note already carries its ConcordChannel gatherer when it flows through + // the Messages-list incremental filter (which routes rows by that gatherer). + val messageRow = + if (rumor is ChatEvent || rumor is CommentEvent) { + val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) + val note = getOrCreateNote(rumor.id) + // Skip attaching a row for a message we already know is deleted (its kind-5 delete + // was processed first). Otherwise every reproject — which re-emits the whole wrap + // buffer — would re-add then re-remove it, churning the feed. justConsume still + // records the (already-known) deletion below; a delete arriving LATER is handled by + // the normal deletion cascade unlinking the note from its gatherers. + if (!deletionIndex.hasBeenDeleted(rumor)) ch.addNote(note) + ch to note + } else { + null + } + // wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature + // check would fail and the event would never load onto its Note — leaving the chat row + // stuck on the "loading / not found" placeholder. Its authenticity is already established + // by the envelope open path (ConcordStreamEnvelope.open verifies the seal signature, + // binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 + // gift-wrapped DM rumor, so we consume it as pre-verified. + justConsume(rumor, null, true) + + // justConsume bails without loading the event when the rumor has already been deleted + // (a kind-5 delete referencing it was processed first — easy to hit in Concord because a + // reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to + // verify. We attached the row up front, so an unpopulated note would otherwise linger as a + // permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message) + // is already handled by the normal deletion cascade unlinking the note from its gatherers. + messageRow?.let { (ch, note) -> + if (note.event == null) ch.removeNote(note) + } + } + fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { if (isValidHex(key)) { return getOrCreatePublicChatChannel(key) @@ -3757,6 +3817,14 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; + // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), + // so — exactly like the 10009 list above — it must be stored replaceably or the Concord + // hub stays empty even after the event arrives. + is ConcordCommunityListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is GroupMetadataEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 441d09feb2..56683a9075 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -26,12 +26,18 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ConcurrentHashMap class ScreenAuthAccount( val account: Account, @@ -49,7 +55,14 @@ class AuthCoordinator( RelayAuthenticator( client, scope, - signWithAllLoggedInUsers = { relayUrl, authTemplate -> + signWithAllLoggedInUsers = { relayUrl, authTemplate, interactive -> + // Concord plane traffic is gated behind NIP-42 as the derived *stream key*, not the + // user: a relay serves a plane's kind-1059 wraps only to a connection authenticated + // as that stream key. These AUTHs expose no user identity (ephemeral derived keys) + // and are signed locally, so we always attach them — independent of the per-account + // policy below — or Concord channels/messages never load. No-op for non-Concord relays. + val streamAuths = signConcordStreamAuths(relayUrl, authTemplate) + // Reconstruct *why* this relay wants auth from what the shared client is doing with // it. Built lazily so accounts that fail the first-party gate below don't pay for it. val context by @@ -63,7 +76,6 @@ class AuthCoordinator( ), ) } - // One socket is shared by every logged-in account, so an AUTH challenge is not tied // to any single one of them. We answer PER ACCOUNT: an account only reveals its // identity to a relay it has a first-party reason to be on (its own inbox/outbox @@ -86,8 +98,24 @@ class AuthCoordinator( RelayAuthVerdict.DENY -> false RelayAuthVerdict.ASK -> { // Prompt at most once per challenge; reuse the answer for any other - // account that also reaches ASK on this same relay. - val choice = askChoice ?: promptBus.requestDecision(relayUrl, context.purposes).also { askChoice = it } + // account that also reaches ASK on this same relay. But never block the + // derived stream-key AUTH behind that dialog: on a relay that hosts our + // Concord planes we DISMISS the user-auth ASK (skip account auth) so the + // stream AUTHs return immediately instead of waiting on a prompt. + // + // A non-[interactive] pass is an automatic re-auth off an `auth-required:` + // CLOSED (e.g. a Concord channel-plane REQ refused because the connection + // AUTHed before the control plane folded in its channel stream keys). It + // must never raise a fresh dialog: DISMISS the account ASK and let only the + // already-approved identities (ledger-ALLOW accounts + stream keys) re-send. + val choice = + askChoice ?: ( + if (streamAuths.isNotEmpty() || !interactive) { + UserAuthChoice.DISMISS + } else { + promptBus.requestDecision(relayUrl, context.purposes) + } + ).also { askChoice = it } when (choice) { UserAuthChoice.ALLOW_ONCE -> true UserAuthChoice.ALWAYS_ALLOW -> { @@ -114,10 +142,36 @@ class AuthCoordinator( } } - signed + signed + streamAuths }, ) + /** + * Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every + * watched account. Signed locally from the derived stream secret (a raw [KeyPair] via + * [NostrSignerSync]) — never the account signer, and never surfacing the user's identity. + */ + private suspend fun signConcordStreamAuths( + relayUrl: NormalizedRelayUrl, + authTemplate: EventTemplate, + ): List { + val secrets = authWithAccounts.distinct().flatMap { it.concordSessions.streamAuthSecretsFor(relayUrl) } + if (secrets.isEmpty()) return emptyList() + return secrets.mapNotNull { secret -> + try { + // Cache the signer by secret so we don't re-derive the secp256k1 keypair for every + // plane on every relay challenge/reconnect. + streamSigners.getOrPut(secret.toHexKey()) { NostrSignerSync(KeyPair(privKey = secret)) }.sign(authTemplate) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e) + null + } + } + } + + // stream secret (hex) -> its local signer. Bounded by joined communities × channels. + private val streamSigners = ConcurrentHashMap() + /** * True when [account] has a first-party reason to authenticate with [relayUrl] on the shared * client: it is publishing its own event there, a subscription there is reading its own diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 0d63313e06..741c99f1eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFil import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler @@ -128,6 +130,15 @@ class RelaySubscriptionsCoordinator( val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed + + // Concord Channels (encrypted communities). One assembler keeps every joined community's + // control + channel planes live (kind-1059 by derived stream address). + val concordChannels = ConcordChannelFilterAssembler(client) + + // On-demand backward history pager for whichever Concord Channel screen is open (older wraps by + // until+limit, per relay), the Concord analog of the per-conversation NIP-04 history. + val concordChannelHistory = ConcordChannelHistoryFilterAssembler(client) + val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -194,6 +205,8 @@ class RelaySubscriptionsCoordinator( relayGroupThreadFeed, relayGroupWarmup, relayGroupsDiscovery, + concordChannels, + concordChannelHistory, account, accountForeground, home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 673e6cba60..ae7323972e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metada import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -85,6 +86,12 @@ val AccountInfoAndListsFromKeyKinds2 = // Loaded up-front so "My Groups" and group memberships resolve immediately at login, // without waiting for the groups screen to mount its own subscription. SimpleGroupListEvent.KIND, + // Concord private joined-communities list (kind 13302, CORD-05): the self-encrypted + // entries carrying each community's secrets. Loaded up-front for the same reason as + // the NIP-29 list above — so communities joined on another device or client (e.g. the + // Armada reference client, sharing this key) surface in the Concord hub at login, + // instead of only appearing after creating/redeeming an invite in Amethyst itself. + ConcordCommunityListEvent.KIND, // NIP-60 Cashu wallet + NIP-61 nutzap info. Replaceables, always // useful to have available — wallet event holds the user's P2PK key // + mint list, nutzap info tells other clients which mints to lock diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index c0ac72aa1e..4a98653b72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity @@ -214,6 +215,38 @@ fun observeNoteReplyCount( return flow.collectAsStateWithLifecycle(note.replies.size) } +/** + * Count of a chat message's **minichat** replies — its kind-1111 [CommentEvent] + * children only (inline quote-replies are ordinary kind-9/42 messages and are not + * counted here). Drives the "N replies" chip that opens the minichat. + * + * Mounting this registers the message with [EventFinderFilterAssemblerSubscription], which + * batches the visible messages' ids into shared REQs for their replies (kind-1111 among + * them) — so for public chats (NIP-28/NIP-29) the thread replies load, and the chip appears, + * just by rendering the rows. Concord's kind-1111 replies instead arrive over the channel + * plane, so that REQ finds nothing there and is a harmless no-op. + */ +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeNoteMinichatReplyCount( + note: Note, + accountViewModel: AccountViewModel, +): State { + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + val flow = + remember(note) { + note + .flow() + .replies.stateFlow + .sample(200) + .mapLatest { it.note.replies.count { reply -> reply.event is CommentEvent } } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(note.replies.count { it.event is CommentEvent }) +} + @Composable fun observeNoteReactions( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 8c6a05081f..d87ea9db23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.model.Account @@ -223,6 +224,7 @@ fun uriToRoute( } relayGroupInviteRoute(uri)?.let { return it } + concordInviteRoute(uri)?.let { return it } val nip19 = Nip19Parser.uriToRoute(uri)?.entity if (nip19 != null) { @@ -355,3 +357,15 @@ private fun relayGroupInviteRoute(uri: String): Route? { val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code) } + +/** + * A shared Concord invite URL (`…/invite/#`). Cheap substring gates + * keep the parse off the hot path; the whole URL (fragment included) is carried into + * the route so the redeem flow still has the unlock token. + */ +private fun concordInviteRoute(uri: String): Route? = + if (uri.contains("/invite/") && uri.contains('#') && ConcordActions.parseInviteLink(uri) != null) { + Route.ConcordInvite(uri) + } else { + null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt new file mode 100644 index 0000000000..72b15cfefe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.launch + +/** + * Renders a Concord invite link (`…/invite/#`) inline as a + * tappable link that opens the redeem flow ([Route.ConcordInvite], which fetches + + * unlocks the bundle and joins). Long-press copies the full link. Falls back to + * plain text if the literal can't be parsed (detection should guarantee it does). + */ +@Composable +fun ClickableConcordInviteLink( + linkText: String, + nav: INav, +) { + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) } + + if (parsed == null) { + Text(text = linkText) + return + } + + val clickableModifier = + remember(linkText) { + Modifier.combinedClickable( + onLongClick = { scope.launch { clipboardManager.setText(linkText) } }, + onClick = { nav.nav(Route.ConcordInvite(linkText)) }, + ) + } + + Text( + text = linkText, + modifier = clickableModifier, + color = MaterialTheme.colorScheme.primary, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 4423e58a9c..32780c6599 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.model.Note @@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -218,6 +221,18 @@ private fun DisplayAddress( return } + // A Concord invite bundle (kind 33301) is addressed by a bare naddr, but redeeming it + // needs the 16-byte unlock token that only lives in the full invite link's #fragment — + // a naddr alone can't be joined. Show an informative label instead of the generic + // (and here always-empty) addressable-note card. + if (nip19.kind == ConcordInviteBundleEvent.KIND) { + Text( + text = stringRes(R.string.concord_invite_naddr_label) + (additionalChars ?: ""), + color = MaterialTheme.colorScheme.primary, + ) + return + } + var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.aTag())) } if (noteBase == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt new file mode 100644 index 0000000000..c577c5d412 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The rich card form of a Concord invite link in note content — the analog of + * NIP-29's `RelayGroupCard`. Tapping the card opens the redeem/join flow + * ([Route.ConcordInvite], which keeps the full URL so the fragment token + * survives). It fetches + unlocks the kind-33301 bundle in the background (via + * [com.vitorpamplona.amethyst.model.Account.peekConcordInvite]) to fill in the + * community name; until then it shows a stable placeholder so layout never jumps. + * + * Degrades to [ClickableConcordInviteLink] (a plain link) if the URL doesn't parse. + */ +@Composable +fun ConcordInviteCard( + linkText: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) } + if (parsed == null) { + ClickableConcordInviteLink(linkText, nav) + return + } + + // Peek the bundle once per link to reveal the community name (null until it resolves). + val invite by produceState(initialValue = null, linkText) { + value = accountViewModel.account.peekConcordInvite(linkText) + } + + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + // Robohash seed: the community id once known (stable), else the link signer. + val robotSeed = invite?.communityId ?: parsed.linkSignerPubKey + val title = invite?.name?.takeIf { it.isNotBlank() } ?: stringRes(R.string.concord_home_title) + + ElevatedCard( + onClick = { nav.nav(Route.ConcordInvite(linkText)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = robotSeed, + model = null, + contentDescription = title, + modifier = + Modifier + .size(52.dp) + .clip(CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.concord_invite_card_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + SymbolIcon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = stringRes(R.string.concord_invite_card_join), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 54e13bb3d9..7d9e46150a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment +import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment @@ -534,6 +535,7 @@ private fun RenderWordWithoutPreview( is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav) + is ConcordInviteLinkSegment -> ClickableConcordInviteLink(word.segmentText, nav) is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel) @@ -574,6 +576,7 @@ private fun RenderWordWithPreview( is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav) + is ConcordInviteLinkSegment -> ConcordInviteCard(word.segmentText, accountViewModel, nav) is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 7e95230683..b4bf3f8c81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -149,7 +149,16 @@ fun RobohashFallbackAsyncImage( val resources = LocalContext.current.resources SubcomposeAsyncImage( - model = ProfilePictureUrl(bridgedModel), + // The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only + // NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at + // file://) would fail there. Route only remote http(s) pictures through the thumbnail + // cache; hand local/content URIs to Coil's native fetchers, which load them directly. + model = + if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) { + ProfilePictureUrl(bridgedModel) + } else { + bridgedModel + }, contentDescription = contentDescription, modifier = modifier, alignment = alignment, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 149d0ff90f..b9b8c34ce6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -107,9 +107,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroup import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat.MinichatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen @@ -637,6 +645,61 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordChannelScreen( + communityId = it.communityId, + channelId = it.channelId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + MinichatScreen( + rootId = it.rootId, + concordCommunityId = it.concordCommunityId, + concordChannelId = it.concordChannelId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordChannelListScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordMembersScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordEditScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordInviteScreen( + link = it.link, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEnd { ConcordHomeScreen(accountViewModel, nav) } + + composableFromEnd { ConcordCreateScreen(accountViewModel, nav) } + composableFromEndArgs { RelayGroupMembersScreen( id = it.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index aeeb9077c4..ea83801688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -67,6 +67,7 @@ enum class NavBarItem { PODCASTS, PUBLIC_CHATS, RELAY_GROUPS, + CONCORD, FOLLOW_PACKS, LIVE_STREAMS, NESTS, @@ -326,6 +327,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Forum, resolveRoute = { Route.RelayGroups }, ), + NavBarItem.CONCORD to + NavBarItemDef( + id = NavBarItem.CONCORD, + labelRes = R.string.concord_home_title, + icon = MaterialSymbols.Group, + resolveRoute = { Route.Concords }, + ), NavBarItem.FOLLOW_PACKS to NavBarItemDef( id = NavBarItem.FOLLOW_PACKS, @@ -448,6 +456,7 @@ val DrawerFeedsItems: List = NavBarItem.COMMUNITIES, NavBarItem.PUBLIC_CHATS, NavBarItem.RELAY_GROUPS, + NavBarItem.CONCORD, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SOFTWARE_APPS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 579b779d5b..fb3c985919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.navigation.routes +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -65,10 +66,42 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +/** + * A minichat reply — a kind-1111 [CommentEvent] posted into a chat message's thread — should open + * that message's minichat ([Route.ChatMinichat]), not the whole channel it lives in. Regular chat + * messages are kind 9 / 42, so a [CommentEvent] in a *chat context* is always a thread reply. We + * gate on the chat context (the reply is attached to a Concord / relay-group / public-chat gatherer, + * or its root message is) precisely so a generic NIP-22 comment on an article or note keeps its own + * thread route and isn't mistaken for a minichat. Returns null when it isn't a chat-context comment. + */ +fun minichatRouteFor(note: Note): Route? { + val comment = note.event as? CommentEvent ?: return null + val rootId = comment.rootEventIds().firstOrNull() ?: return null + + // Prefer the reply's own Concord channel (the reply arrived over that plane, so its gatherer + // always carries the community/channel), else the root note's if it happens to be loaded. Passing + // these lets the minichat screen resolve the plane + relays even when the parent isn't cached. + val concord = + note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + ?: LocalCache.getNoteIfExists(rootId)?.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concord != null) { + return Route.ChatMinichat(rootId, concord.channelId.communityId, concord.channelId.channelId) + } + + val inChatContext = note.isInChatGatherer() || LocalCache.getNoteIfExists(rootId)?.isInChatGatherer() == true + return if (inChatContext) Route.ChatMinichat(rootId) else null +} + +private fun Note.isInChatGatherer(): Boolean = inGatherers?.any { it is ConcordChannel || it is RelayGroupChannel || it is PublicChatChannel } == true + fun routeFor( note: Note, loggedIn: Account, ): Route? { + // A minichat reply opens the message's thread, not the whole channel it belongs to. Must run + // before the channel-gatherer shortcuts below, which would otherwise swallow it into the channel. + minichatRouteFor(note)?.let { return it } + // Marmot group messages should navigate to the group chat val marmotGroup = note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } if (marmotGroup != null) { @@ -84,6 +117,15 @@ fun routeFor( return routeFor(relayGroup) } + // Concord channel content (kind 9 chat, 1111 reply, 7 reaction) lands in LocalCache as a real + // Note attached to its ConcordChannel gatherer. Route to the Concord chat instead of the generic + // thread view it would otherwise fall through to: a minichat reply (kind-1111) opens its thread + // ([minichatRouteFor]); a top-level message / reaction opens the channel. + val concordChannel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concordChannel != null) { + return minichatRouteFor(note) ?: routeFor(concordChannel) + } + val noteEvent = note.event ?: return Route.EventRedirect(note.idHex) if (noteEvent.isGroupScoped()) { @@ -282,6 +324,8 @@ fun routeFor(note: RelayGroupChannel): Route = Route.RelayGroup(note.groupId.id, fun routeFor(groupId: GroupId): Route = Route.RelayGroup(groupId.id, groupId.relayUrl.url) +fun routeFor(channel: ConcordChannel): Route = Route.Concord(channel.channelId.communityId, channel.channelId.channelId) + fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex) fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex) @@ -301,6 +345,17 @@ fun routeReplyTo( return Route.MarmotGroupChat(marmotGroup.nostrGroupId, replyId = note.idHex) } + // Concord messages are end-to-end encrypted: a reply must go through the channel plane (a sealed + // kind-1111), never a public kind-1111 — which would leak the private rumor id onto public relays + // and wouldn't bind to the channel. Route the reply into the message's minichat, whose composer + // sends the wrapped reply. For a thread reply (kind-1111) reply into the same flat thread (its + // root); for a top-level message (kind-9) the message itself is the thread root. + val concord = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concord != null) { + val rootId = (note.event as? CommentEvent)?.rootEventIds()?.firstOrNull() ?: note.idHex + return Route.ChatMinichat(rootId, concord.channelId.communityId, concord.channelId.channelId) + } + val noteEvent = note.event return when (noteEvent) { is ChannelMessageEvent -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 15be5ed14d..b16cc4dfd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -678,6 +678,49 @@ sealed class Route { @Serializable object RelayGroupBrowse : Route() + // Concord Channels (encrypted communities). Addressed by community id + channel id + // (both lowercase hex), never a host relay — a channel plane may be mirrored on all + // of the community's relays. + @Serializable data class Concord( + val communityId: String, + val channelId: String, + val draftId: HexKey? = null, + val replyTo: HexKey? = null, + ) : Route() + + @Serializable data class ConcordServer( + val communityId: String, + ) : Route() + + @Serializable data class ConcordMembers( + val communityId: String, + ) : Route() + + @Serializable data class ConcordEdit( + val communityId: String, + ) : Route() + + @Serializable object ConcordCreate : Route() + + // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. + @Serializable data class ConcordInvite( + val link: String, + ) : Route() + + @Serializable object Concords : Route() + + // The "minichat" of a chat message: its kind-1111 thread replies, opened from the message and + // rendered as a chat-within-a-chat. Keyed by the root message id; the screen resolves the chat + // context (Concord channel, public chat, relay group) from the note's gatherer. When opened from + // a Concord reply whose parent message may not be cached, [concordCommunityId]/[concordChannelId] + // carry the plane context (taken from the reply's channel), so the screen can still subscribe the + // plane and backfill the parent — without them, a reply to an unloaded parent can't pick the relay. + @Serializable data class ChatMinichat( + val rootId: HexKey, + val concordCommunityId: String? = null, + val concordChannelId: String? = null, + ) : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b1810aac12..5ae73506f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -58,6 +58,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill @@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits @@ -200,6 +202,7 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.observeZapSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay @@ -1452,6 +1455,7 @@ private fun RenderNoteRow( makeItShort, canPreview, quotesLeft, + unPackReply, backgroundColor, accountViewModel, nav, @@ -1907,6 +1911,16 @@ fun FirstUserInfoRow( PrivateRumorMark(accountViewModel) } + // A Concord message (e.g. surfaced in Notifications) names its parent community with the same + // highlighted chip the Messages row uses, tapping through to that community's channel list. + val concordChannel = remember(baseNote) { baseNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } } + concordChannel?.communityName?.let { communityName -> + ConcordCommunityPill( + communityName = communityName, + onClick = { nav.nav(Route.ConcordServer(concordChannel.channelId.communityId)) }, + ) + } + if (isPinned) { PinnedMark() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 7d4d6ce281..d78cb1919d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -274,6 +274,31 @@ fun CardBody( val isOwnNote = accountViewModel.isLoggedUser(note.author) val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) + // Concord moderation: only present when this account may actually act. + val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null } + val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) } + val showConcordBanDialog = remember { mutableStateOf(false) } + + if (showConcordBanDialog.value) { + QuickActionAlertDialogOneButton( + title = stringRes(R.string.concord_ban_user_title), + textContent = stringRes(R.string.concord_ban_user_body), + buttonIcon = MaterialSymbols.Gavel, + buttonText = stringRes(R.string.concord_ban_user), + buttonColors = + ButtonDefaults.buttonColors( + containerColor = LightRedColor, + contentColor = Color.White, + ), + onClickDoOnce = { + accountViewModel.banConcordMember(note) + showConcordBanDialog.value = false + onDismiss() + }, + onDismiss = { showConcordBanDialog.value = false }, + ) + } + Column(modifier = Modifier.width(IntrinsicSize.Min)) { Row(modifier = Modifier.height(IntrinsicSize.Min)) { NoteQuickActionItem( @@ -449,6 +474,30 @@ fun CardBody( showReportDialog.value = true } } + + if (concordAdmin != null) { + VerticalDivider(color = primaryLight) + + val isAdmin = concordAdmin.third + NoteQuickActionItem( + MaterialSymbols.Shield, + stringRes(if (isAdmin) R.string.concord_remove_admin else R.string.concord_make_admin), + ) { + accountViewModel.toggleConcordAdmin(note) + onDismiss() + } + } + + if (canConcordBan) { + VerticalDivider(color = primaryLight) + + NoteQuickActionItem( + MaterialSymbols.Gavel, + stringRes(R.string.concord_ban_user), + ) { + showConcordBanDialog.value = true + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt index e51612e3a7..53c00eaa9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,10 +36,13 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent @Composable fun RenderChat( @@ -46,6 +50,7 @@ fun RenderChat( makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, + unPackReply: ReplyRenderType, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, @@ -65,6 +70,27 @@ fun RenderChat( overflow = TextOverflow.Ellipsis, ) } else { + // A kind-9 chat message carries its reply target as a NIP-18 `q` (or NIP-10 `e`) + // tag, NOT as a NIP-10 thread — so it's not a BaseThreadedEvent and RenderTextEvent's + // reply-to preview never fires for it. Render the quoted parent here so a Concord/MLS + // chat reply shows what it's replying to wherever NoteCompose draws it (Notifications + // tab, feed, threads) — the chat feed has its own reply-row and passes NONE. + if (unPackReply == ReplyRenderType.FULL && !makeItShort) { + val replyingDirectlyTo = + remember(note) { + // Skip the preview when the parent is already cited inline (`nostr:...`) in the + // message — quotesLeft renders it at that spot, so a top preview would duplicate + // it. Happens with MLS/WhiteNoise quotes; Concord `q` replies aren't cited inline. + note.replyTo?.lastOrNull()?.takeUnless { parent -> + (noteEvent as? BaseNoteEvent)?.findCitations()?.contains(parent.idHex) == true + } + } + if (replyingDirectlyTo != null) { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + } + val callbackUri = remember(note) { note.toNostrUri() } SensitivityWarning( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index a3a3ec542a..2ee381764c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -190,6 +190,15 @@ class AccountFeedContentStates( } } + // Same for the Concord view mode (inline channels vs one row per community). + scope.launch(Dispatchers.IO) { + account.settings.concordViewMode + .drop(1) + .collect { + dmKnown.invalidateData() + } + } + // Pinning/unpinning a room only changes sort order, not membership, so no // chat event flows through LocalCache. Force a rebuild to re-sort. This // also fires when pins arrive via the synced AppSpecificData event. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b554cf1a71..8a03fc1da2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -348,7 +349,7 @@ class AccountViewModel( RelayAuthenticator( newClient, customScope, - signWithAllLoggedInUsers = { _, authTemplate -> + signWithAllLoggedInUsers = { _, authTemplate, _ -> if (account.signer.isWriteable()) { try { listOf(account.signer.sign(authTemplate)) @@ -541,6 +542,14 @@ class AccountViewModel( note: Note, reaction: String, ) { + // Concord messages are encrypted: a public kind-7 would e-tag the private rumor id onto + // public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an + // existing Concord reaction is a follow-up; for now this only adds one.) + if (note.inGatherers?.any { it is ConcordChannel } == true) { + launchSigner { account.reactToConcordMessage(note, reaction) } + return + } + launchSigner { val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { @@ -582,6 +591,64 @@ class AccountViewModel( reactToOrDelete(note, reaction) } + /** Ban the author of a Concord channel message (no-op unless this account may ban them). */ + fun banConcordMember(note: Note) { + val (communityId, member) = account.concordBanTarget(note) ?: return + launchSigner { account.banConcordMember(communityId, member) } + } + + /** Toggle the Admin role on the author of a Concord channel message (owner only). */ + fun toggleConcordAdmin(note: Note) { + val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return + launchSigner { + if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member) + } + } + + /** Promote/demote [member] as an Admin of [communityId] (from the Members roster; owner only takes effect). */ + fun setConcordAdmin( + communityId: String, + member: HexKey, + makeAdmin: Boolean, + ) = launchSigner { + if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member) + } + + /** Ban/unban [member] from [communityId] (from the Members roster). */ + fun setConcordBan( + communityId: String, + member: HexKey, + ban: Boolean, + ) = launchSigner { + if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) + } + + /** + * Remove [member] from [communityId] absolutely (CORD-06 Refounding): rotate the + * community key so the member's key stops working for anything sent afterwards. + * Heavier than a ban (re-keys every retained member); owner / BAN-holder only. + */ + fun removeConcordMember( + communityId: String, + member: HexKey, + ) = launchSigner { + account.refoundConcordCommunity(communityId, setOf(member)) + } + + /** Pull the account's Concord community list from the stock + own relays (Concord hub bootstrap). */ + fun importConcordCommunities() = + viewModelScope.launch(Dispatchers.IO) { + account.importConcordCommunities() + } + + /** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */ + fun sendConcordTyping( + communityId: String, + channelIdHex: String, + ) = viewModelScope.launch(Dispatchers.IO) { + account.sendConcordTyping(communityId, channelIdHex) + } + @Immutable data class NoteComposeReportState( val isPostHidden: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index dab8a5916d..a05ddaf6ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription @@ -134,6 +135,8 @@ private fun PreloadFor( NavBarItem.RELAY_GROUPS -> RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 95119bb921..bd284893cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent @@ -294,6 +295,22 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { + // Concord plane wraps are kind-1059 too, but their `p` tag is ephemeral and + // the payload opens with a derived plane key, not our identity — so route + // them to the Concord read-path first. A recognized wrap is fully handled + // there (folded / re-projected) and must not fall through to the DM path. + if (account.concordSessions.ingest(event)) { + // Concord typing heartbeats ride kind-21059 ephemeral wraps that arrive + // continuously while anyone in any joined community is composing. NIP-01 + // ephemeral events (20000–29999) must never be persisted; the session has + // already folded the state they carried, so drop the durable wrap note now + // to keep LocalCache from growing without bound. + if (event is EphemeralGiftWrapEvent) { + cache.unlinkAndRemove(listOf(eventNote)) + } + return + } + if (event.recipientPubKey() != account.signer.pubKey) return val innerGiftId = event.innerEventId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 79bb11bde3..3a6eb83c27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -89,6 +90,11 @@ fun LoggedInPage( // Loads account information + DMs and Notifications from Relays. AccountFilterAssemblerSubscription(accountViewModel) + // Preloads every joined Concord community's Control planes app-wide (their wraps are addressed to + // derived stream keys, so the always-on DM tail can't pick them up) — so communities fold, and + // their channels/metadata/icon appear, without waiting for a Concord screen to be opened. + ConcordChannelPreload(accountViewModel) + // Foreground-only loaders: follows-outbox finder + random-relay notifications. // Pauses on ON_STOP, resumes on ON_START. AccountForegroundFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index f6b09bb796..ba65bcbde0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -21,17 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,8 +49,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -96,6 +110,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as MaterialSymbolIcon @Composable fun ChatroomMessageCompose( @@ -280,6 +295,8 @@ fun NormalChatNote( ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + MinichatReplyChip(note, accountViewModel, nav) + val geo = remember(note) { note.event?.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) @@ -384,6 +401,63 @@ private fun MessageBubbleLines( } } +/** + * A chip on a chat message's action row showing how many kind-1111 thread ("minichat") + * replies it has; tapping opens that thread. Shown only when there is at least one — an + * inline reply is an ordinary message and isn't counted. + * + * Only shown where minichats are actually wired: the public chats (Concord, NIP-28, NIP-29). + * NIP-17 DMs are deliberately excluded — most clients don't render kind-1111 replies in a DM + * view, so a thread there would be a dead end. + */ +@Composable +private fun MinichatReplyChip( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val supportsMinichat = + remember(note) { + note.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true + } + if (!supportsMinichat) return + + val count by observeNoteMinichatReplyCount(note, accountViewModel) + if (count > 0) { + Spacer(StdHorzSpacer) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable { nav.nav(Route.ChatMinichat(note.idHex)) }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + MaterialSymbolIcon( + symbol = MaterialSymbols.Forum, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(13.dp), + ) + Text( + text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +/** + * Id of a note whose reply-to preview should be suppressed on a message row. Set by a + * thread view that already pins that note at the top (the minichat pins its root), so each + * reply doesn't redundantly re-render the same parent as an inner quote. Null everywhere else. + */ +val LocalSuppressReplyToNoteId = compositionLocalOf { null } + @Composable fun RenderReplyRow( note: Note, @@ -396,7 +470,8 @@ fun RenderReplyRow( onScrollToNote: ((Note) -> Unit)? = null, ) { val replyTo = note.replyTo?.lastOrNull() - if (!innerQuote && replyTo != null && !isCitedInContent(note, replyTo)) { + val suppressId = LocalSuppressReplyToNoteId.current + if (!innerQuote && replyTo != null && replyTo.idHex != suppressId && !isCitedInContent(note, replyTo)) { RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt new file mode 100644 index 0000000000..fe21faf8bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn + +/** + * The reactive read-model of a message's minichat: the root's kind-1111 [CommentEvent] + * thread replies, oldest-first, filtered by the account's block/ban rules. + * + * Driven off the root [Note]'s replies flow, so it recomputes whenever a reply arrives — + * from the plane (Concord), a relay subscription (public chats), or the local echo of the + * user's own send. Wherever the minichat is opened from, the list stays live. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MinichatFeedViewModel( + val rootNote: Note, + val account: Account, +) : ViewModel() { + val replies: StateFlow> = + rootNote + .flow() + .replies.stateFlow + .mapLatest { collectReplies() } + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, collectReplies()) + + private fun collectReplies(): List = + rootNote.replies + .filter { it.event is CommentEvent && account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) + + class Factory( + val rootNote: Note, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = MinichatFeedViewModel(rootNote, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt new file mode 100644 index 0000000000..b7bc4e23bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The minichat ("chat within a chat") of a single chat message: the message pinned at + * top, then its kind-1111 thread replies as chat bubbles, with a composer that always + * posts a kind-1111 rooted at that message (flat — replying inside a minichat doesn't + * spawn sub-threads). Opened from the "N replies" chip on any chat message. + * + * Self-sufficient regardless of where it's opened from: it mounts its own datasource so + * replies keep flowing (the Concord plane subscription when the root is a Concord message; + * a relay reply subscription for public chats), and drives the list from a reactive + * [MinichatFeedViewModel] so new replies appear (and auto-scroll into view) live. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MinichatScreen( + rootId: String, + concordCommunityId: String? = null, + concordChannelId: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) } + + // Resolve the Concord channel from the (loaded) root note's gatherer, else from the ids threaded + // in by the reply that opened this minichat. The latter is what lets a reply-to-an-unloaded-parent + // still pick the plane + relays: the reply arrived over the channel plane, so its channel id is known. + val concordChannel = remember(rootNote) { rootNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } } + val communityId = concordCommunityId ?: concordChannel?.channelId?.communityId + val channelId = concordChannelId ?: concordChannel?.channelId?.channelId + val isConcord = communityId != null && channelId != null + + // Datasource: keep the thread replies flowing no matter the entry point. Concord replies arrive + // over the channel plane, so mount the plane subscription plus this channel's backward-history + // pager and page it until the parent message loads (see [ConcordMinichatBackfillUntilRoot]); + // public-chat (NIP-28/NIP-29) replies come over a relay REQ for this message's kind-1111 children. + if (isConcord) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + ConcordChannelHistorySubscription(communityId!!, channelId!!, accountViewModel.dataSources().concordChannelHistory, accountViewModel) + ConcordMinichatBackfillUntilRoot(rootNote, accountViewModel.dataSources().concordChannelHistory.history) + } + EventFinderFilterAssemblerSubscription(rootNote, accountViewModel) + + val feedViewModel: MinichatFeedViewModel = + viewModel( + key = rootId + "MinichatFeedViewModel", + factory = MinichatFeedViewModel.Factory(rootNote, accountViewModel.account), + ) + val replies by feedViewModel.replies.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + // Auto-scroll to the newest reply as they arrive (root is item 0, replies follow). + LaunchedEffect(replies.size) { + if (replies.isNotEmpty()) listState.animateScrollToItem(replies.size) + } + + val composer = remember { TextFieldState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val canPost by remember { derivedStateOf { composer.text.isNotBlank() } } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.chat_minichat_title), maxLines = 1) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + actions = { + // For a Concord thread, always offer a jump to the full channel — the "chat room + // itself", where the whole timeline loads and (if you aren't a member yet) you can + // join. This is the way out when the pinned root is still backfilling or unavailable. + if (isConcord) { + IconButton(onClick = { nav.nav(Route.Concord(communityId!!, channelId!!)) }) { + SymbolIcon(symbol = MaterialSymbols.Forum, contentDescription = stringRes(R.string.concord_open_channel)) + } + } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxHeight().padding(padding)) { + // Every reply here is rooted at [rootNote], which is already pinned at the top — so + // suppress the redundant reply-to-root preview each reply would otherwise render. + CompositionLocalProvider(LocalSuppressReplyToNoteId provides rootId) { + LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().weight(1f, true)) { + item("root") { + ChatroomMessageCompose( + baseNote = rootNote, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + HorizontalDivider() + } + items(replies, key = { it.idHex }) { reply -> + ChatroomMessageCompose( + baseNote = reply, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + } + } + } + + Column(modifier = EditFieldModifier) { + ThinPaddingTextField( + state = composer, + modifier = Modifier.fillMaxWidth(), + shape = EditFieldBorder, + placeholder = { + Text( + text = stringRes(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + ThinSendButton( + isActive = canPost, + modifier = EditFieldTrailingIconModifier, + ) { + val text = composer.text.toString().trim() + if (text.isNotEmpty()) { + composer.clearText() + scope.launch(Dispatchers.IO) { + try { + accountViewModel.account.sendMinichatReply(rootNote, text) + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } + } + } +} + +/** + * Pages the Concord channel's backward history until the minichat's parent message loads (or the + * relays are exhausted). Reaching a minichat from a reply notification can land on a parent that + * isn't in the live tail; the reply itself pinned the channel context, so we can walk the plane + * history back to fetch the parent — after which its whole kind-1111 thread projects normally. The + * `!loading` gate serializes pages; once the root's event is present, or nothing is left, it latches off. + */ +@Composable +private fun ConcordMinichatBackfillUntilRoot( + rootNote: Note, + history: ConcordChannelHistorySubAssembler, +) { + LaunchedEffect(rootNote, history) { + combine(history.loadingMore, history.status) { loading, status -> + rootNote.event == null && !loading && !status.exhausted + }.distinctUntilChanged() + .filter { it } + .collect { history.advanceAll() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt new file mode 100644 index 0000000000..1ec3fcc42f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The channel list of one Concord community (the "server" view). Reads the folded + * Control Plane from the community session and renders one row per channel; tapping + * opens that channel's [ConcordChannelScreen]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordChannelListScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + val account = accountViewModel.account + // Re-resolve on each revision so a deep link that lands before the session exists picks it up. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } + val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }) + .collectAsStateWithLifecycle() + + val scope = rememberCoroutineScope() + var inviteLink by remember { mutableStateOf(null) } + var minting by remember { mutableStateOf(false) } + + // Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the + // fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer. + val canManageChannels = + state?.authority?.let { + it.isOwner(account.signer.pubKey) || + it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS) + } == true + + // channelIdHex == null → create; else → rename that channel. + var channelEditor by remember { mutableStateOf(null) } + var channelToDelete by remember { mutableStateOf(null) } + + inviteLink?.let { link -> + InviteLinkDialog(link = link, onDismiss = { inviteLink = null }) + } + + channelEditor?.let { editor -> + ConcordChannelEditDialog( + initialName = editor.initialName, + isCreate = editor.channelIdHex == null, + onDismiss = { channelEditor = null }, + onConfirm = { newName -> + channelEditor = null + scope.launch { + if (editor.channelIdHex == null) { + account.createConcordChannel(communityId, newName) + } else { + account.renameConcordChannel(communityId, editor.channelIdHex, newName) + } + } + }, + ) + } + + channelToDelete?.let { target -> + val id = target.channelIdHex ?: return@let + AlertDialog( + onDismissRequest = { channelToDelete = null }, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_title)) }, + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_message, target.initialName)) }, + confirmButton = { + TextButton(onClick = { + channelToDelete = null + scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) } + }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { channelToDelete = null }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel)) + } + }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + // Prefer the folded metadata name, then the stored community name from the list + // entry (always present from the join/create — this is what shows everywhere else). + // Fall back to the app name only if neither exists (should be unreachable), never as + // the normal "metadata hasn't folded yet" placeholder — that showed "Amy Debug". + val title = + state?.metadata?.name + ?: session?.entry?.name?.ifBlank { null } + ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name) + Text(title, maxLines = 1) + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } + }, + actions = { + val canEdit = + state?.authority?.let { + it.isOwner(account.signer.pubKey) || + it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA) + } == true + + IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) { + SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title)) + } + if (canEdit) { + IconButton(onClick = { nav.nav(Route.ConcordEdit(communityId)) }) { + SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title)) + } + } + IconButton( + enabled = !minting, + onClick = { + minting = true + scope.launch { + try { + inviteLink = account.mintConcordInvite(communityId) + } finally { + // Always clear the flag — a thrown mint would otherwise leave the + // button disabled until the screen is recreated. + minting = false + } + } + }, + ) { + SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + } + }, + ) + }, + floatingActionButton = { + if (canManageChannels) { + FloatingActionButton( + onClick = { channelEditor = ConcordChannelEditor(channelIdHex = null, initialName = "") }, + shape = CircleShape, + ) { + SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_create)) + } + } + }, + ) { padding -> + val channels = + state + ?.channels + ?.entries + ?.toList() + .orEmpty() + if (channels.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(channels, key = { it.key }) { entry -> + val def = entry.value.definition + val name = def?.name ?: entry.key + val icon = + when { + def?.voice == true -> MaterialSymbols.Mic + def?.private == true -> MaterialSymbols.Lock + else -> MaterialSymbols.Tag + } + Row( + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Concord(communityId, entry.key)) } + .padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1) + if (canManageChannels) { + ConcordChannelRowMenu( + onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + ) + } + } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +/** A pending channel create ([channelIdHex] null) or rename target. */ +private data class ConcordChannelEditor( + val channelIdHex: String?, + val initialName: String, +) + +/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */ +@Composable +private fun ConcordChannelRowMenu( + onRename: () -> Unit, + onDelete: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + SymbolIcon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_rename)) }, + onClick = { + expanded = false + onRename() + }, + ) + DropdownMenuItem( + text = { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + expanded = false + onDelete() + }, + ) + } + } +} + +/** Name-entry dialog for creating a new channel or renaming an existing one. */ +@Composable +private fun ConcordChannelEditDialog( + initialName: String, + isCreate: Boolean, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var name by remember { mutableStateOf(initialName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringRes( + if (isCreate) { + com.vitorpamplona.amethyst.R.string.concord_channel_create + } else { + com.vitorpamplona.amethyst.R.string.concord_channel_rename + }, + ), + ) + }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_name_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, + ) { + Text( + stringRes( + if (isCreate) { + com.vitorpamplona.amethyst.R.string.concord_channel_create + } else { + com.vitorpamplona.amethyst.R.string.concord_channel_rename_save + }, + ), + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel)) + } + }, + ) +} + +/** Shows a freshly minted invite link as a QR code with copy + share actions. */ +@Composable +private fun InviteLinkDialog( + link: String, + onDismiss: () -> Unit, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_title)) }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + QrCodeDrawer(contents = link, modifier = Modifier.size(220.dp)) + Text( + text = link, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 12.dp), + ) + } + }, + confirmButton = { + TextButton(onClick = { + val send = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, link) + } + context.startActivity(Intent.createChooser(send, stringRes(context, com.vitorpamplona.amethyst.R.string.concord_invite_title))) + onDismiss() + }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.quick_action_share)) + } + }, + dismissButton = { + TextButton(onClick = { + scope.launch { clipboard.setText(link) } + onDismiss() + }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.copy_to_clipboard)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt new file mode 100644 index 0000000000..c18bd22474 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -0,0 +1,570 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The chat screen of one Concord Channel. Messages are real Notes in [LocalCache] + * attached to the channel, so the feed reuses the shared [RefreshingChatroomFeedView] + * (avatars, reactions, replies, zaps, OTS) and the composer reuses the same + * @-mention / inline-mention / reply machinery as the other chats. Only the send + * path is Concord-specific: it wraps the message on the channel plane. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordChannelScreen( + communityId: String, + channelId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + // Load the user's custom-emoji packs so the `:shortcode:` composer autocomplete has entries. + WatchAndLoadMyEmojiList(accountViewModel) + ConcordChannelHistorySubscription(communityId, channelId, accountViewModel.dataSources().concordChannelHistory, accountViewModel) + + val account = accountViewModel.account + val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) } + + val feedViewModel: ChannelFeedViewModel = + viewModel( + key = channel.channelId.toKey() + "ConcordFeedViewModel", + factory = ChannelFeedViewModel.Factory(channel, account), + ) + WatchLifecycleAndUpdateModel(feedViewModel) + + // Backward history pager for this open channel (older wraps by until+limit, per relay), mirroring + // the NIP-04 per-conversation history: markers drive paging while on screen, a status card sits at + // the oldest end, and an empty channel bootstraps one page so there is something to scroll from. + val history = remember(accountViewModel) { accountViewModel.dataSources().concordChannelHistory.history } + val loadingHistory by history.loadingMore.collectAsStateWithLifecycle() + val historyStatus by history.status.collectAsStateWithLifecycle() + val limits = + remember(historyStatus) { + buildList { + if (!historyStatus.exhausted) { + historyStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("cord:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "Concord") { history.advance(relay) }) + } + } + } + } + ConcordBackfillHistoryToWindow(feedViewModel.feedState, history) + + val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") + newMessageModel.init(accountViewModel) + newMessageModel.load(communityId, channelId) + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(channel.toBestDisplayName(), maxLines = 1) + channel.communityName?.let { + Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxHeight().padding(padding)) { + Column(Modifier.fillMaxHeight().weight(1f, true)) { + RefreshingChatroomFeedView( + feedContentState = feedViewModel.feedState, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = concordChannelLastReadRoute(communityId, channelId), + onWantsToReply = { newMessageModel.reply(it) }, + onWantsToEditDraft = {}, + // A status card at the oldest end: shows what it's reaching for while it pages and + // crossfades to "All caught up" when every relay runs dry. + olderBoundary = { + DmHistoryLoadingCard( + "Concord", + "Concord", + loadingHistory, + historyStatus.exhausted, + historyStatus.relayCount, + historyStatus.stalledCount, + historyStatus.reachedBack, + historyStatus.relayProgress, + ::formatHistoryReachDate, + ) + }, + // Each relay's window-limit marker at its reached cursor (pure UI). Hidden when exhausted. + markersInGap = + if (limits.isEmpty()) { + null + } else { + { newer, older -> RelayReachMarkers(limits, newer, older) {} } + }, + // Pulls each relay's next page while its marker is on screen, off viewport visibility. + sentinels = + if (limits.isEmpty()) { + null + } else { + { items, listState -> + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + } + }, + ) + } + + ConcordTypingIndicator(communityId, channelId, accountViewModel) + + if (channel.canPost()) { + Spacer(modifier = DoubleVertSpacer) + ConcordMessageComposer( + newMessageModel = newMessageModel, + accountViewModel = accountViewModel, + nav = nav, + onMessageSent = { feedViewModel.feedState.sendToTop() }, + ) + } + } + } +} + +/** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */ +private const val CONCORD_HISTORY_TARGET = 50 + +/** + * On open, eagerly backfill this channel's older history until the feed holds at least + * [CONCORD_HISTORY_TARGET] messages (or the relays are exhausted) — mirroring Armada's multi-page + * `backfillStore`. The live subscription only carries each plane's relay-capped recent tail, shared + * across one merged REQ per relay for every channel; so without this, a channel that has plenty of + * history opens showing just its last few messages until the user scrolls. Once the target is reached, + * paging is purely demand-driven by the markers' visibility. A short startup delay skips the transient + * empty feed that navigation flashes through. Supersedes the old empty-only bootstrap. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +private fun ConcordBackfillHistoryToWindow( + feedContentState: FeedContentState, + history: ConcordChannelHistorySubAssembler, +) { + LaunchedEffect(feedContentState, history) { + delay(1200L) + // Reactive count of currently-loaded messages (0 while empty/loading). + val loadedCount = + feedContentState.feedContent.flatMapLatest { state -> + when (state) { + is FeedState.Loaded -> state.feed.map { it.list.size } + else -> flowOf(0) + } + } + // Pull another page whenever we're below target, no page is in flight, and relays aren't done. + // Each landed page grows the count (or flips exhausted), so this re-fires page-by-page and then + // latches off. The !loading gate prevents overlapping REQs / a tight loop. + combine(loadedCount, history.loadingMore, history.status) { count, loading, status -> + count < CONCORD_HISTORY_TARGET && !loading && !status.exhausted + }.distinctUntilChanged() + .filter { it } + .collect { history.advanceAll() } + } +} + +/** Republish a typing heartbeat at most this often (seconds) while composing. */ +private const val TYPING_HEARTBEAT_SECS = 4L + +/** + * A slim "X is typing…" line above the composer, driven by the session's ephemeral + * typing heartbeats (kind 23311). A ~2s ticker re-applies the freshness window so a + * typist who stops silently fades out even without a new ingest. + */ +@Composable +private fun ConcordTypingIndicator( + communityId: String, + channelId: String, + accountViewModel: AccountViewModel, +) { + val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return + val typingMap by session.typing.collectAsStateWithLifecycle() + + var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) } + // Only tick while this channel actually has heartbeats, and stop once they've all aged out of + // the freshness window — an idle channel must not wake a 2s recomposition loop forever. A new + // heartbeat re-keys this effect (the map value changes) and restarts the fade. + LaunchedEffect(session, channelId, typingMap[channelId]) { + val perChannel = typingMap[channelId] + if (perChannel.isNullOrEmpty()) return@LaunchedEffect + while (true) { + nowSecs = TimeUtils.now() + if (perChannel.values.none { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS }) break + delay(2000L) + } + } + + val typers = + remember(typingMap, channelId, nowSecs) { + (typingMap[channelId] ?: emptyMap()) + .filterValues { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS } + .keys + .sorted() + } + + if (typers.isEmpty()) return + + val label = + when (typers.size) { + 1 -> stringRes(R.string.concord_typing_one, rememberTypistName(typers[0], accountViewModel)) + 2 -> + stringRes( + R.string.concord_typing_two, + rememberTypistName(typers[0], accountViewModel), + rememberTypistName(typers[1], accountViewModel), + ) + else -> stringRes(R.string.concord_typing_many) + } + + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } +} + +/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */ +@Composable +private fun rememberTypistName( + hex: String, + accountViewModel: AccountViewModel, +): String { + val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) } + val info by observeUserInfo(user, accountViewModel) + return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() } +} + +private fun reachState(p: RelayPagingProgress): RelayReachState = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .removePrefix("wss://") + .removePrefix("ws://") + .removeSuffix("/") + +@Composable +private fun ConcordMessageComposer( + newMessageModel: ConcordNewMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, + onMessageSent: suspend () -> Unit, +) { + val scope = rememberCoroutineScope() + val canPost by remember { derivedStateOf { newMessageModel.canPost() } } + val context = LocalContext.current + + // Throttle typing heartbeats to at most one every few seconds while the field is non-empty. + val lastTypingSecs = remember(newMessageModel.channelId) { longArrayOf(0L) } + + DisposableEffect(newMessageModel.channelId) { + onDispose { newMessageModel.userSuggestions?.reset() } + } + + // Encrypted image attachments: a picked image opens this dialog, which encrypts + uploads via the + // shared NIP-17 pipeline and sends an Armada-shaped image message on the channel plane. + newMessageModel.uploadState?.let { uploadState -> + uploadState.multiOrchestrator?.let { + ConcordFileUploadDialog( + newMessageModel = newMessageModel, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } + } + + newMessageModel.replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() } + ReplyModeToggle( + mode = newMessageModel.replyMode.value, + onToggle = { newMessageModel.toggleReplyMode() }, + ) + } + + Column(modifier = EditFieldModifier) { + newMessageModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + newMessageModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, + ) + } + + newMessageModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + newMessageModel::autocompleteWithEmoji, + newMessageModel::autocompleteWithEmoji, + SuggestionListDefaultHeightChat, + ) + } + + ThinPaddingTextField( + state = newMessageModel.message, + onTextChanged = { + newMessageModel.onMessageChanged() + val community = newMessageModel.communityId + val channel = newMessageModel.channelId + val now = TimeUtils.now() + if (community != null && channel != null && + newMessageModel.message.text.isNotEmpty() && + now - lastTypingSecs[0] >= TYPING_HEARTBEAT_SECS + ) { + lastTypingSecs[0] = now + accountViewModel.sendConcordTyping(community, channel) + } + }, + onContentReceived = { uri, mimeType -> + newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType))) + }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + shape = EditFieldBorder, + placeholder = { + Text( + text = stringRes(com.vitorpamplona.amethyst.R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + ) { + SelectFromGallery( + isUploading = false, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier, + onImageChosen = newMessageModel::pickedMedia, + ) + } + }, + trailingIcon = { + ThinSendButton( + isActive = canPost, + modifier = EditFieldTrailingIconModifier, + ) { + scope.launch(Dispatchers.IO) { + try { + newMessageModel.sendPost() + onMessageSent() + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +/** + * The picked-image confirmation dialog for a Concord channel. Reuses the shared NIP-17 upload + * pipeline: it always encrypts (no encryption toggle is shown, and [SuccessfulUploads.toConcordImeta] + * fails closed if a cipher is somehow absent), uploads the ciphertext, then sends one Armada-shaped + * image message ([Account.sendConcordChannelImageMessage]) carrying every attachment's `imeta`. + */ +@Composable +private fun ConcordFileUploadDialog( + newMessageModel: ConcordNewMessageViewModel, + state: ChatFileUploadState, + accountViewModel: AccountViewModel, + nav: INav, + onUpload: suspend () -> Unit, + onCancel: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + ChatFileUploadDialog( + state = state, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_send_image_title)) }, + upload = { + scope.launch(Dispatchers.IO) { + val community = newMessageModel.communityId + val channel = newMessageModel.channelId + if (community == null || channel == null) return@launch + + ChatFileUploader(accountViewModel.account).justUploadNIP17( + viewState = state, + onError = { title, message -> + scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + }, + onEncryptedUploadError = { title, message -> + scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + }, + context = context, + onceUploaded = { uploads -> + val imetas = uploads.mapNotNull { it.toConcordImeta() } + if (imetas.isNotEmpty()) { + accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas) + } + onUpload() + }, + ) + + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) + } + }, + onCancel = onCancel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +/** + * Turns an encrypted upload into the Armada-shaped `imeta` (via [ChannelChat.encryptedImageImeta]). + * Returns null when the upload carried no cipher — so a non-encrypted blob is never sent as a Concord + * image (fails closed, protecting the community's end-to-end guarantee). + */ +private fun SuccessfulUploads.toConcordImeta(): IMetaTag? { + val cipher = cipher ?: return null + return ChannelChat.encryptedImageImeta( + url = result.url, + mimeType = result.mimeTypeBeforeEncryption, + dim = result.fileHeader.dim?.toString(), + blurhash = result.fileHeader.blurHash?.blurhash, + cipher = cipher, + originalHash = result.hashBeforeEncryption, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt new file mode 100644 index 0000000000..c6f38c3dbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +// Decrypt-once memo per plaintext hash → the on-disk file:// model. Object URLs never change for a +// given hash (content-addressed), so this is safe to keep for the process lifetime and bounded by the +// number of distinct community images a session touches. +private val resolvedByHash = ConcurrentHashMap() + +/** + * Resolve a CORD-02 §6 community [pointer] to a model string for [RobohashFallbackAsyncImage]: + * + * - **null / blank** → null (caller falls back to the robohash). + * - **plain URL** (a url-only pointer, e.g. Amethyst's own metadata form) → the URL, loaded directly. + * - **encrypted** (`key`/`nonce`/`hash` present) → fetch the ciphertext, AES-256-GCM-decrypt + verify + * the plaintext SHA-256 (all in [ImagePointer.decryptOrNull]), cache the plaintext to disk, and + * return its `file://` path. Returns null while loading or on any fetch/decrypt/integrity failure, + * so a swapped or unreachable blob simply shows the robohash instead of garbage. + */ +@Composable +fun rememberConcordImageModel( + pointer: ImagePointer?, + accountViewModel: AccountViewModel, +): String? { + if (pointer == null) return null + + // A url-only pointer isn't encrypted media — hand the URL straight to Coil. + if (!pointer.isResolvable()) return pointer.url.ifBlank { null } + + val context = LocalContext.current + val model by produceState(resolvedByHash[pointer.hash], pointer, accountViewModel) { + if (value != null) return@produceState + value = + withContext(Dispatchers.IO) { + runCatching { + resolvedByHash[pointer.hash]?.let { return@runCatching it } + + val cacheFile = File(context.cacheDir, "concord-img-${pointer.hash}") + if (!cacheFile.exists()) { + val client = accountViewModel.httpClientBuilder.okHttpClientForImage(pointer.url) + val ciphertext = + client.newCall(Request.Builder().url(pointer.url).build()).execute().use { resp -> + if (!resp.isSuccessful) return@runCatching null + resp.body?.bytes() + } ?: return@runCatching null + + val plaintext = pointer.decryptOrNull(ciphertext) ?: return@runCatching null + cacheFile.writeBytes(plaintext) + } + val uri = "file://${cacheFile.absolutePath}" + resolvedByHash[pointer.hash] = uri + uri + }.onFailure { Log.w("ConcordImage", "Failed to resolve community image ${pointer.url}", it) } + .getOrNull() + } + } + return model +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt new file mode 100644 index 0000000000..4fa775cc6f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +/** + * A tappable chip naming the Concord community a message belongs to. Deliberately **muted** — the same + * faint wash the note-header markers use — because the community's logo is now the row avatar, so the + * name only needs to read as tappable metadata, not compete with it. (The NIP-29 relay-host chip stays + * highlighted; a relay group has no avatar of its own.) Shared by the Messages row and the Notifications + * feed so a Concord message reads the same wherever it surfaces; the name is hard-capped so a long title + * can't crowd the row. + */ +@Composable +fun ConcordCommunityPill( + communityName: String, + onClick: () -> Unit, + maxChars: Int = 20, +) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f), + contentColor = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + symbol = MaterialSymbols.Group, + contentDescription = null, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(11.dp), + ) + Text( + text = if (communityName.length > maxChars) communityName.take(maxChars).trimEnd() + "…" else communityName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt new file mode 100644 index 0000000000..e92283510e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Create a new Concord Channel (encrypted community) from Amethyst. Mints the + * genesis (metadata + #general), publishes it to the chosen relays (or the + * account's outbox by default), joins it, and opens the new community. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordCreateScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val name = remember { mutableStateOf("") } + val about = remember { mutableStateOf("") } + val icon = remember { mutableStateOf(null) } + val relays = remember { mutableListOf().toMutableStateList() } + var working by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + ConcordMetadataFields( + name = name, + about = about, + icon = icon, + robotSeed = "concord-new", + accountViewModel = accountViewModel, + ) + + ConcordSectionHeader( + title = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays), + description = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays_desc), + ) + ConcordRelayListEditor( + relays = relays, + onRemove = { relays.remove(it) }, + onAdd = { if (it !in relays) relays.add(it) }, + accountViewModel = accountViewModel, + nav = nav, + ) + + Button( + onClick = { + if (name.value.isBlank() || working) return@Button + working = true + scope.launch { + val communityId = + try { + accountViewModel.account.createConcordCommunity( + name = name.value.trim(), + description = about.value.trim().ifBlank { null }, + relays = relays.map { it.url }, + icon = icon.value, + ) + } finally { + // Always re-enable — a thrown create would otherwise strand the button. + working = false + } + if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) + } + }, + enabled = name.value.isNotBlank() && !working, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_action)) + } + } + } +} + +/** A section header (title + one-line description) matching the NIP-29 metadata form. */ +@Composable +fun ConcordSectionHeader( + title: String, + description: String? = null, +) { + Column(Modifier.fillMaxWidth().padding(top = 4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt new file mode 100644 index 0000000000..63397ae827 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Edit a Concord community's metadata (name / description / icon). Reuses the shared + * [ConcordMetadataFields] hero + fields, prefilled from the folded Control Plane, and + * saves a new metadata edition via [com.vitorpamplona.amethyst.model.Account.editConcordMetadata] + * — honored on fold only when this account holds MANAGE_METADATA (or is the owner). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordEditScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + + // Self-sufficient: mount the plane subscription so a deep link folds the community, and + // re-resolve the session on each revision so it resolves once the session exists. + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } + val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + + val name = remember { mutableStateOf("") } + val about = remember { mutableStateOf("") } + val icon = remember { mutableStateOf(null) } + val banner = remember { mutableStateOf(null) } + val relays = remember { mutableStateListOf() } + var prefilled by remember { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // Seed the fields once, the first time the folded metadata is available. Relays come from the + // folded metadata when present, else from this account's list entry (the bootstrap set). + LaunchedEffect(state?.metadata) { + val md = state?.metadata + if (!prefilled && md != null) { + name.value = md.name + about.value = md.description.orEmpty() + icon.value = md.icon + banner.value = md.banner + val seededRelays = (md.relays.takeIf { it.isNotEmpty() } ?: session?.entry?.relays.orEmpty()) + relays.clear() + relays.addAll(seededRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) + prefilled = true + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.concord_edit_title), maxLines = 1) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + if (session == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + ConcordMetadataFields( + name = name, + about = about, + icon = icon, + robotSeed = communityId, + accountViewModel = accountViewModel, + banner = banner, + ) + + ConcordSectionHeader( + title = stringRes(R.string.concord_create_relays), + description = stringRes(R.string.concord_edit_relays_desc), + ) + ConcordRelayListEditor( + relays = relays, + onRemove = { relays.remove(it) }, + onAdd = { if (it !in relays) relays.add(it) }, + accountViewModel = accountViewModel, + nav = nav, + ) + + Button( + onClick = { + if (name.value.isBlank() || working) return@Button + working = true + scope.launch { + val ok = + try { + account.editConcordMetadata( + communityId = communityId, + name = name.value.trim(), + description = about.value.trim().ifBlank { null }, + icon = icon.value, + banner = banner.value, + relays = relays.map { it.url }, + ) + } finally { + // Always re-enable — a thrown save would otherwise strand the button. + working = false + } + if (ok) nav.popBack() + } + }, + enabled = name.value.isNotBlank() && !working, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Text(stringRes(R.string.concord_edit_save)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt new file mode 100644 index 0000000000..632e929f77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -0,0 +1,550 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import kotlinx.coroutines.flow.combine +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The Concord Channels hub — a single-screen browser of every community the account + * joined (kind-13302) and, expanded inline, that community's channels. Each row is a + * community you can expand to reveal its `#`/🔒/🎙 channels without leaving the screen. + * Tapping a channel opens its chat; the community header opens the full server view. + * + * Concord has no public directory — communities are E2E-encrypted and invite-gated — + * so there's no browse feed: you arrive by creating one, redeeming an invite, or (for + * a key already used on another Concord client) the on-open import from the stock + * relays below. The live plane subscription is mounted here so channels fold in while + * you browse. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordHomeScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + val account = accountViewModel.account + val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() + // Re-read folded metadata (name / icon / channels) whenever a Control Plane folds. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + + // Concord clients (Armada/Vector) publish the kind-13302 joined list to the Concord + // stock relays, not the user's outbox, so a community joined there never surfaces at + // login. Pull it from those relays when the hub opens — scoped here so we only reach + // the stock relays for users who actually use Concord. + LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() } + + // Per-community expansion, cycled on tap: absent = CLOSED → UNREAD (peek only the channels with + // new messages) → OPEN (all channels) → CLOSED. Multi-open, so several can be expanded at once. + // rememberSaveable so the chevron states survive opening a channel and coming back to the hub. + var expandStates by rememberSaveable(stateSaver = ExpandStatesSaver) { mutableStateOf(emptyMap()) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.concord_home_title)) }, + navigationIcon = { + // Back arrow only when this is a pushed screen (from the drawer / a deep link); + // as a bottom-nav root there is nothing to pop and the bar takes its place. + if (nav.canPop()) { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + } + }, + ) + }, + bottomBar = { + // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // so the same screen works both as a pushed destination and a bottom-nav tab. + AppBottomBar(Route.Concords, nav, accountViewModel) { route -> + if (route != Route.Concords) nav.navBottomBar(route) + } + }, + floatingActionButton = { + FabBottomBarPadded(nav) { + FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }, shape = CircleShape) { + SymbolIcon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.concord_create_title), + modifier = Modifier.size(24.dp), + ) + } + } + }, + ) { padding -> + if (communities.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringRes(R.string.concord_home_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + return@Scaffold + } + + // Busiest communities first: sort by the most-recent message across their channels so the + // one you'd actually open floats to the top (recomputed as messages fold in on `revision`). + val sorted = + remember(communities, revision) { + communities.sortedByDescending { entry -> + val keys = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + ?.channels + ?.keys + .orEmpty() + communityActivity(entry.id, keys) + } + } + + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + sorted.forEach { entry -> + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + .takeIf { revision >= 0 } + val mode = expandStates[entry.id] ?: ChannelExpand.CLOSED + val channelKeys = state?.channels?.keys.orEmpty() + + item(key = entry.id) { + CommunityHeader( + communityId = entry.id, + name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, + iconPointer = state?.metadata?.icon, + channelKeys = channelKeys, + revision = revision, + mode = mode, + accountViewModel = accountViewModel, + onSetMode = { next -> expandStates = if (next == ChannelExpand.CLOSED) expandStates - entry.id else expandStates + (entry.id to next) }, + onOpen = { nav.nav(Route.ConcordServer(entry.id)) }, + ) + } + + if (mode != ChannelExpand.CLOSED && state != null) { + // A banner hero (CORD-02 §6) belongs to the full view, not the compact unread peek. + if (mode == ChannelExpand.OPEN) { + state.metadata?.banner?.let { banner -> + item(key = "banner-${entry.id}") { CommunityBanner(banner, accountViewModel) } + } + } + // Channels, most-recently-active first, each with its last message + unread state. + // In UNREAD mode a row hides itself unless it has new messages (peek). + val channels = + state.channels.entries.sortedByDescending { + LocalCache.getConcordChannelIfExists(ConcordChannelId(entry.id, it.key))?.lastNote?.createdAt() ?: 0L + } + items(channels, key = { "${entry.id}/${it.key}" }) { ch -> + val def = ch.value.definition + ConcordChannelRow( + communityId = entry.id, + channelKey = ch.key, + channelName = def?.name ?: ch.key, + icon = + when { + def?.voice == true -> MaterialSymbols.Mic + def?.private == true -> MaterialSymbols.Lock + else -> MaterialSymbols.Tag + }, + hideIfRead = mode == ChannelExpand.UNREAD, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, + ) + } + if (mode == ChannelExpand.UNREAD) { + item(key = "showall-${entry.id}") { + ShowAllChannelsRow(onClick = { expandStates = expandStates + (entry.id to ChannelExpand.OPEN) }) + } + } + } + item(key = "div-${entry.id}") { + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +/** Most-recent message time across a community's [channelKeys] (0 if none), for activity sorting. */ +private fun communityActivity( + communityId: String, + channelKeys: Set, +): Long = + channelKeys.maxOfOrNull { key -> + LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, key))?.lastNote?.createdAt() ?: 0L + } ?: 0L + +/** + * The number of a community's channels with a message newer than this account last read there — + * combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on + * [revision] so a freshly-folded message flips the badge. + */ +@Composable +private fun communityUnreadCount( + account: Account, + communityId: String, + channelKeys: Set, +): Int { + if (channelKeys.isEmpty()) return 0 + // Keyed only on the channel set (not the global revision): each per-channel flow reacts to both + // its last-read marker AND the channel's own notes flow, so a folded message flips the badge + // without tearing down and restarting every flow on every unrelated fold (which reset the badge + // to 0 and made it flicker). + val flow = + remember(communityId, channelKeys) { + combine( + channelKeys.map { key -> + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key)) + combine( + account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)), + channel.flow().notes.stateFlow, + ) { lastRead, state -> + val last = state.channel.lastNote?.createdAt() ?: 0L + if (last > lastRead) 1 else 0 + } + }, + ) { flags -> flags.sum() } + } + return flow.collectAsStateWithLifecycle(0).value +} + +@Composable +private fun CommunityHeader( + communityId: String, + name: String, + iconPointer: ImagePointer?, + channelKeys: Set, + revision: Int, + mode: ChannelExpand, + accountViewModel: AccountViewModel, + onSetMode: (ChannelExpand) -> Unit, + onOpen: () -> Unit, +) { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) + val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys) + // Tap cycles CLOSED → UNREAD → OPEN → CLOSED, skipping the UNREAD peek when nothing is unread + // (so a quiet community never lands on an empty middle state). + val next = + when (mode) { + ChannelExpand.CLOSED -> if (unread > 0) ChannelExpand.UNREAD else ChannelExpand.OPEN + ChannelExpand.UNREAD -> ChannelExpand.OPEN + ChannelExpand.OPEN -> ChannelExpand.CLOSED + } + Row( + modifier = Modifier.fillMaxWidth().clickable { onSetMode(next) }.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = communityId, + model = iconModel, + contentDescription = name, + modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onOpen), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + Column(Modifier.weight(1f)) { + Text( + name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (unread > 0) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val memberCount = + remember(revision) { + accountViewModel.account.concordSessions + .sessionFor(communityId) + ?.memberCount() ?: 0 + } + val parts = mutableListOf() + if (channelKeys.isNotEmpty()) parts += pluralStringResource(R.plurals.concord_channel_count, channelKeys.size, channelKeys.size) + if (memberCount > 0) parts += pluralStringResource(R.plurals.concord_member_count, memberCount, memberCount) + val subtitle = parts.joinToString(" · ") + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (unread > 0) UnreadBadge(unread) + SymbolIcon( + // ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal"). + symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** The community's decrypted CORD-02 §6 banner as a hero strip; renders nothing until it resolves. */ +@Composable +private fun CommunityBanner( + banner: ImagePointer, + accountViewModel: AccountViewModel, +) { + val model = rememberConcordImageModel(banner, accountViewModel) ?: return + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + RobohashFallbackAsyncImage( + robot = "", + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(110.dp), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = false, + autoPlayGif = autoPlayGif, + ) +} + +/** A small pill showing the unread-channel count next to a community. */ +@Composable +private fun UnreadBadge(count: Int) { + Box( + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 7.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** + * One channel row with its last message (author + snippet), relative time, and an unread marker — + * bold + a dot when there's a message newer than this account last read the channel. + */ +@Composable +private fun ConcordChannelRow( + communityId: String, + channelKey: String, + channelName: String, + icon: MaterialSymbol, + hideIfRead: Boolean, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val account = accountViewModel.account + // getOrCreate (not getIfExists): a channel folded in the control plane may have no message-buffer + // note yet, and caching that null for the row's lifetime would leave it perpetually blank. The + // channel's own notes flow then makes lastNote reactive, so the preview/unread dot appears the + // moment its first message folds in — without keying on the global revision (which flickered the + // whole row on every unrelated fold). + val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) } + val channelState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + val lastNote = channelState.channel.lastNote + val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle() + val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime + + // In the UNREAD peek, a read channel simply isn't shown. + if (hideIfRead && !unread) return + + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 40.dp, end = 16.dp) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(Modifier.weight(1f)) { + Text( + channelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val note = lastNote + val event = note?.event + val author = note?.author + val preview: String? = + if (author != null && event != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${event.content.take(80)}" + } else { + event?.content?.take(80) + } + preview?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + lastNote?.createdAt()?.let { ts -> + Text( + timeAgo(ts, LocalContext.current, prefix = ""), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (unread) { + Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) + } + } +} + +/** The footer in the UNREAD peek that expands a community to all of its channels. */ +@Composable +private fun ShowAllChannelsRow(onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 40.dp, end = 16.dp) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon( + symbol = MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + stringRes(R.string.concord_show_all_channels), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +/** How much of a community's channel list the hub shows, cycled by tapping its header. */ +private enum class ChannelExpand { + /** Header only. */ + CLOSED, + + /** Only channels with messages newer than this account last read them (the "peek"). */ + UNREAD, + + /** Every channel, plus the banner hero. */ + OPEN, +} + +/** + * Saver for the per-community expand map so the chevron states survive navigation (open a channel, + * come back). Serializes to an `ArrayList` of `"communityId=MODE"` — community ids are hex, + * so `=` never collides. + */ +private val ExpandStatesSaver = + Saver, ArrayList>( + save = { map -> ArrayList(map.map { "${it.key}=${it.value.name}" }) }, + restore = { list -> + list.associate { + val sep = it.lastIndexOf('=') + it.substring(0, sep) to ChannelExpand.valueOf(it.substring(sep + 1)) + } + }, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt new file mode 100644 index 0000000000..89abba6951 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import android.content.Context +import android.net.Uri +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Authors a CORD-02 §6 encrypted community image and returns the [ImagePointer] to seal in the + * community metadata — the exact inverse of [rememberConcordImageModel]'s read path, and the same + * scheme Armada's `encryptImageBlob` + Blossom upload uses in `concord-v2/lib/image.ts`. + * + * Rather than hand-roll the upload, this drives the **standard** [UploadOrchestrator.uploadEncrypted] + * pipeline that DM/chat encrypted media uses, so a community icon gets the same treatment as any + * other attachment: image compression ([CompressorQuality]), EXIF/metadata stripping, and the + * account's configured Blossom server. The only Concord-specific parts are the fresh [AESGCM] cipher + * (whose key/nonce we keep to build the pointer) and mapping the orchestrator's result — the + * ciphertext `url` plus the *plaintext* `hashBeforeEncryption` — into the [ImagePointer] shape. + */ +class ConcordImageUploader( + private val account: Account, +) { + /** + * Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer. + * + * Runs on [Dispatchers.IO]: the compression/upload pipeline asserts it is off the main thread + * ([MediaCompressor] calls `checkNotInMainThread`), and callers launch this from a Compose + * `rememberCoroutineScope()`, which is Main-dispatched — so without this switch the first step + * throws before any bytes leave the device. + */ + suspend fun uploadEncrypted( + uri: Uri, + context: Context, + ): ImagePointer = + withContext(Dispatchers.IO) { + // Fresh random key + nonce per image; we hold onto them to build the pointer below since the + // orchestrator only surfaces the ciphertext URL, not the cipher it was handed. + val cipher = AESGCM() + + val finalState = + UploadOrchestrator().uploadEncrypted( + uri = uri, + mimeType = context.contentResolver.getType(uri), + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.MEDIUM, + encrypt = cipher, + server = resolveBlossomServer(), + account = account, + context = context, + ) + + val result = + when (finalState) { + is UploadingState.Finished -> finalState.result + is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params)) + } + + val server = + result as? UploadOrchestrator.OrchestratorResult.ServerResult + ?: throw IllegalStateException("Encrypted community image upload did not return a server URL") + + ImagePointer( + url = server.url, + key = cipher.keyBytes.toHexKey(), + nonce = cipher.nonce.toHexKey(), + // hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it + // after decrypting, so it must match what was actually encrypted, not the original file. + hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"), + ) + } + + /** The account's first configured Blossom server, wrapped as a [ServerName], else the default. */ + private fun resolveBlossomServer(): ServerName { + val configured = + account.blossomServers + .getBlossomServersList() + ?.servers() + ?.firstOrNull() + return if (configured != null) { + ServerName(configured, configured, ServerType.Blossom) + } else { + DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt new file mode 100644 index 0000000000..31c400395f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +private sealed interface RedeemState { + data object Working : RedeemState + + data class Done( + val communityId: String, + ) : RedeemState + + data object Failed : RedeemState +} + +/** + * Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]). + * On open it fetches + unlocks the bundle, joins the community, and forwards to its + * channel list. On failure it offers a retry, so a transient relay miss doesn't + * strand the user. + */ +@Composable +fun ConcordInviteScreen( + link: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + var state by remember(link) { mutableStateOf(RedeemState.Working) } + + LaunchedEffect(link, state) { + if (state is RedeemState.Working) { + val communityId = accountViewModel.account.joinConcordViaInvite(link) + state = if (communityId != null) RedeemState.Done(communityId) else RedeemState.Failed + } + } + + LaunchedEffect(state) { + (state as? RedeemState.Done)?.let { done -> + nav.newStack(Route.ConcordServer(done.communityId)) + } + } + + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state) { + is RedeemState.Working -> { + CircularProgressIndicator() + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite), + modifier = Modifier.padding(top = 16.dp), + textAlign = TextAlign.Center, + ) + } + + is RedeemState.Failed -> { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_failed), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Button( + onClick = { state = RedeemState.Working }, + modifier = Modifier.padding(top = 16.dp), + ) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.retry)) + } + } + + is RedeemState.Done -> Unit + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordLastRead.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordLastRead.kt new file mode 100644 index 0000000000..65b819fd52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordLastRead.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +/** + * The per-account last-read route key for one Concord channel — the string + * [com.vitorpamplona.amethyst.model.Account.markAsRead]/`loadLastReadFlow` are keyed by. + * Shared by the write side (the open channel marks messages read as they show) and the + * read side (the hub's unread indicators) so the two can never drift apart. + */ +fun concordChannelLastReadRoute( + communityId: String, + channelId: String, +): String = "Concord/$communityId/$channelId" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMemberHarvest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMemberHarvest.kt new file mode 100644 index 0000000000..c68a503873 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMemberHarvest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * How far back the member-roster harvest pages each channel. Concord membership includes every observed + * author (CORD-02 §5), but they only appear by decrypting their messages — so a complete roster needs + * history, not just the live tail. This bounds the history pulled onto the device: members whose last + * post predates the window aren't counted. Tune here to trade completeness for data. + */ +private const val CONCORD_MEMBER_HARVEST_WINDOW_SECS = 90L * 24 * 60 * 60 + +/** + * A headless, run-once background sweep that fills in a Concord community's **full** member roster. + * + * The live channel subscriptions only carry the recent tail the relay serves, so [observedAuthors] + * (CORD-02 §5) sees only recent posters — a fraction of the real membership. This pages every folded + * channel's history back to [CONCORD_MEMBER_HARVEST_WINDOW_SECS] in one pooled fetch. The wraps ride + * the app's normal ingest (the global `CacheClientConnector` → `concordSessions.ingest`), which decrypts + * each and folds its author into `observedAuthors` — so the members screen's count fills in as the sweep + * runs, with no extra plumbing here. `session.beginMemberHarvest()` gates it to once per community. + * + * Mount it from the members screen; it self-cancels with the composition. AUTH is free — the channel + * planes' stream keys are already registered for these relays (same path the live sub uses). + */ +@Composable +fun ConcordMemberHarvest( + communityId: String, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(communityId, revision) { account.concordSessions.sessionFor(communityId) } + + androidx.compose.runtime.LaunchedEffect(session, revision) { + val s = session ?: return@LaunchedEffect + // Each folded channel's derived plane pubkey is a REQ author. Empty until the Control Plane folds + // its channels — a later revision re-runs this effect, so we harvest as soon as they appear. + val planePks = s.channelAddresses().toList() + if (planePks.isEmpty()) return@LaunchedEffect + if (!s.beginMemberHarvest()) return@LaunchedEffect + + val relays = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.relays + ?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + .orEmpty() + if (relays.isEmpty()) return@LaunchedEffect + + val filter = + Filter( + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = planePks, + since = TimeUtils.now() - CONCORD_MEMBER_HARVEST_WINDOW_SECS, + ) + withContext(Dispatchers.IO) { + runCatching { + // Events land via the global ingest path, so onEvent is a no-op — we only drive the paging. + account.client.fetchAllPagesFromPool(relays.associateWith { listOf(filter) }) { _, _ -> } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt new file mode 100644 index 0000000000..feb28e6c4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordMembership +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The members roster of one Concord community — the analog of NIP-29's + * `RelayGroupMembersScreen`. Concord has no relay-signed roster (membership is key + * possession), so this shows the *privileged* roster derivable from the folded + * Control Plane: the owner, every role-holder (admins/moderators), and banned + * users. The overflow menu offers promote/demote (owner) and ban/unban, gated on + * the viewer's authority exactly as the write path enforces on fold. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordMembersScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + + // Self-sufficient: mount the Concord plane subscription so the community folds even when this + // screen is opened directly (deep link), not only from the hub. + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + // Page every channel's bounded history once so the roster includes observed authors who only posted + // outside the live tail (CORD-02 §5) — the difference between a handful of recent posters and the + // real membership. + ConcordMemberHarvest(communityId, accountViewModel) + + // Re-resolve the session as sessions are created/folded (revision-keyed), so a deep link that + // lands before the community's session exists still picks it up once it does. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } + val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + + // The Guestbook membership (self-signed joins) plus everyone seen publishing a channel message + // (observed authors, CORD-02 §5) — most members never post a Join, so without the latter the + // roster collapses to just the owner + privileged roles. + val guestbookMembers by (session?.members ?: remember { MutableStateFlow(emptySet()) }).collectAsStateWithLifecycle() + val observedAuthors by (session?.observedAuthors ?: remember { MutableStateFlow(emptySet()) }).collectAsStateWithLifecycle() + + val myPubKey = account.signer.pubKey + val roster = + remember(state, guestbookMembers, observedAuthors) { + val s = state ?: return@remember emptyList() + val authority = s.authority + val pubkeys = + (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers + observedAuthors) + .map { it.lowercase() } + .distinct() + pubkeys + .map { + // The member's most-privileged role name (lowest position ranks highest), so the + // roster shows the real "Admin"/"Moderator"/custom label instead of a coarse badge. + val roleName = + authority + .rolesFor(it) + .minByOrNull { r -> r.position } + ?.name + ?.takeIf { n -> n.isNotBlank() } + RosterEntry(it, ConcordMembership.of(authority, it), roleName) + }.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey })) + } + + val iAmOwner = state?.authority?.isOwner(myPubKey) == true + val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(stringRes(R.string.concord_members_title), maxLines = 1) + state?.metadata?.name?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + if (roster.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringRes(R.string.concord_members_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(roster, key = { it.pubkey }) { entry -> + ConcordMemberRow( + entry = entry, + communityId = communityId, + isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true), + viewerIsOwner = iAmOwner, + viewerCanBan = iCanBan, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +@Composable +private fun ConcordMemberRow( + entry: RosterEntry, + communityId: String, + isSelf: Boolean, + viewerIsOwner: Boolean, + viewerCanBan: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = remember(entry.pubkey) { accountViewModel.checkGetOrCreateUser(entry.pubkey) } + val isOwnerTarget = entry.membership == ConcordMembership.OWNER + val isBanned = entry.membership == ConcordMembership.BANNED + val isAdmin = entry.membership == ConcordMembership.ADMIN + + // Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders, + // never against the owner or yourself. A banned user only offers "unban". + val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf + val canBan = viewerCanBan && !isOwnerTarget && !isSelf + // Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban. + val canRemove = viewerCanBan && !isOwnerTarget && !isSelf + val hasMenu = canToggleAdmin || canBan || canRemove + + var confirmRemove by remember { mutableStateOf(false) } + if (confirmRemove) { + ConcordRemoveMemberDialog( + onConfirm = { + accountViewModel.removeConcordMember(communityId, entry.pubkey) + confirmRemove = false + }, + onDismiss = { confirmRemove = false }, + ) + } + + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + if (user != null) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } else { + Text(entry.pubkey.take(8), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + MemberBadge(entry.membership, entry.roleName) + if (hasMenu) { + var expanded by remember { mutableStateOf(false) } + IconButton(onClick = { expanded = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (canToggleAdmin) { + DropdownMenuItem( + text = { Text(stringRes(if (isAdmin) R.string.concord_members_remove_admin else R.string.concord_members_make_admin)) }, + onClick = { + accountViewModel.setConcordAdmin(communityId, entry.pubkey, makeAdmin = !isAdmin) + expanded = false + }, + ) + } + if (canBan) { + DropdownMenuItem( + text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) }, + onClick = { + accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned) + expanded = false + }, + ) + } + if (canRemove) { + DropdownMenuItem( + text = { + Text( + stringRes(R.string.concord_members_remove), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + confirmRemove = true + expanded = false + }, + ) + } + } + } + } +} + +/** A small pill labelling the member's standing (owner / role name / banned; plain members render nothing). */ +@Composable +private fun MemberBadge( + membership: ConcordMembership, + roleName: String?, +) { + val label = + when { + membership == ConcordMembership.BANNED -> stringRes(R.string.concord_role_banned) + membership == ConcordMembership.OWNER -> stringRes(R.string.concord_role_owner) + // Show the actual granted role ("Admin", "Moderator", or a custom role) rather than a + // one-size-fits-all badge; fall back to the generic "Admin" label if a role-holder's + // role name somehow didn't resolve. + roleName != null -> roleName + membership == ConcordMembership.ADMIN -> stringRes(R.string.concord_role_admin) + else -> return + } + val container = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer + val content = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer + Surface(shape = RoundedCornerShape(6.dp), color = container) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = content, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */ +@Composable +private fun ConcordRemoveMemberDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.concord_members_remove_title)) }, + text = { Text(stringRes(R.string.concord_members_remove_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringRes(R.string.concord_members_remove_confirm), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +private class RosterEntry( + val pubkey: HexKey, + val membership: ConcordMembership, + /** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */ + val roleName: String?, +) + +/** Owner first, then admins, then plain members, then banned last. */ +private fun ConcordMembership.sortRank(): Int = + when (this) { + ConcordMembership.OWNER -> 0 + ConcordMembership.ADMIN -> 1 + ConcordMembership.MEMBER -> 2 + ConcordMembership.NONE -> 3 + ConcordMembership.BANNED -> 4 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt new file mode 100644 index 0000000000..c70b4b3f57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The shared metadata form for creating and editing a Concord community — a large circular icon + * hero at the top (tap to pick an image, which is AES-256-GCM-encrypted and uploaded to Blossom as + * a CORD-02 §6 [ImagePointer], see [ConcordImageUploader]), then the name and description fields. + * Mirrors the NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features feel + * consistent. Callers own the state and add the surrounding scaffold, relays section (create only), + * and the create/save action. + */ +@Composable +fun ConcordMetadataFields( + name: MutableState, + about: MutableState, + icon: MutableState, + robotSeed: String, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + banner: MutableState? = null, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + banner?.let { ConcordBannerHero(banner = it, accountViewModel = accountViewModel) } + + ConcordIconHero( + robotSeed = robotSeed, + icon = icon, + displayName = name.value, + accountViewModel = accountViewModel, + ) + + OutlinedTextField( + value = name.value, + onValueChange = { name.value = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringRes(R.string.concord_create_name)) }, + ) + OutlinedTextField( + value = about.value, + onValueChange = { about.value = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 5, + label = { Text(stringRes(R.string.concord_create_about)) }, + ) + } +} + +/** + * The circular community-icon hero: shows the current (decrypted) icon over a stable robohash + * placeholder, and on tap opens the photo picker → encrypts + uploads the chosen image and updates + * [icon] to the resulting encrypted pointer. A spinner covers the hero while the upload is in flight. + */ +@Composable +private fun ConcordIconHero( + robotSeed: String, + icon: MutableState, + displayName: String, + accountViewModel: AccountViewModel, +) { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var uploading by remember { mutableStateOf(false) } + val iconModel = rememberConcordImageModel(icon.value, accountViewModel) + + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + uploading = true + scope.launch { + try { + icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("ConcordImageUpload", "Community icon upload failed", e) + val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details) + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } finally { + uploading = false + } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier + .size(104.dp) + .clip(CircleShape) + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + contentAlignment = Alignment.Center, + ) { + RobohashFallbackAsyncImage( + robot = robotSeed, + model = iconModel, + contentDescription = displayName.ifBlank { stringRes(R.string.concord_create_title) }, + modifier = Modifier.size(104.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + if (uploading) CircularProgressIndicator(modifier = Modifier.size(36.dp)) + } + Text( + text = stringRes(R.string.concord_create_icon_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(top = 8.dp) + .clip(CircleShape) + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +/** + * A wide community-banner hero (a 3:1 header image): shows the current decrypted banner, and on tap + * opens the photo picker → AES-256-GCM-encrypts + uploads the image and updates [banner] to the + * resulting CORD-02 §6 encrypted pointer. Tapping when a banner is set replaces it; a small remove + * button clears it. A spinner covers the hero while the upload is in flight. + */ +@Composable +private fun ConcordBannerHero( + banner: MutableState, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var uploading by remember { mutableStateOf(false) } + val bannerModel = rememberConcordImageModel(banner.value, accountViewModel) + + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + uploading = true + scope.launch { + try { + banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("ConcordImageUpload", "Community banner upload failed", e) + val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details) + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } finally { + uploading = false + } + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(3f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + contentAlignment = Alignment.Center, + ) { + if (bannerModel != null) { + AsyncImage( + model = bannerModel, + contentDescription = stringRes(R.string.concord_edit_banner_hint), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(3f), + ) + } + if (uploading) { + CircularProgressIndicator(modifier = Modifier.size(36.dp)) + } else if (bannerModel == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + SymbolIcon( + symbol = MaterialSymbols.AddPhotoAlternate, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringRes(R.string.concord_edit_banner_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 6.dp), + ) + } + } + if (bannerModel != null && !uploading) { + IconButton( + onClick = { banner.value = null }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + SymbolIcon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.remove), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +/** + * The community's bootstrap-relay list editor, shared by the create and edit screens. Each relay is + * shown the same way the Relay Settings screens show them — the relay's NIP-11 favicon, its + * advertised name, and its host — and tapping the row opens the full relay-info page ([Route.RelayInfo]), + * so a community relay is a first-class, inspectable relay rather than a bare URL string. A trailing + * ✕ removes it; the [RelayUrlEditField] below adds one. State is owned by the caller. + */ +@Composable +fun ConcordRelayListEditor( + relays: List, + onRemove: (NormalizedRelayUrl) -> Unit, + onAdd: (NormalizedRelayUrl) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + relays.forEach { relay -> + ConcordRelayRow(relay, { onRemove(relay) }, accountViewModel, nav) + } + RelayUrlEditField( + onNewRelay = onAdd, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConcordRelayRow( + relay: NormalizedRelayUrl, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + // The NIP-11 relay-info doc (icon + display name), fetched + cached exactly like the settings rows. + val relayInfo by loadRelayInfo(relay) + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + RenderRelayIcon( + displayUrl = relayInfo.id ?: relay.displayUrl(), + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + pingInMs = 0, + iconModifier = MediumRelayIconModifier, + ) + Spacer(Modifier.width(10.dp)) + Column( + Modifier + .weight(1f) + .combinedClickable( + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, + onLongClick = { scope.launch { clipboard.setText(relay.url) } }, + ), + ) { + relayInfo.name?.takeIf { it.isNotBlank() }?.let { name -> + Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + text = relay.displayUrl(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + IconButton(onClick = onRemove) { + SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt new file mode 100644 index 0000000000..74c1219214 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource + +import com.vitorpamplona.amethyst.commons.actions.ConcordPlaneSub +import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** One screen's request to keep the user's joined Concord Channels live. */ +class ConcordChannelQueryState( + val account: Account, +) + +/** + * Keeps every joined Concord community's planes live while a Concord-bearing + * screen is on top — the Concord analog of `RelayGroupMyJoinedGroupsFilterAssembler`. + * + * Unlike NIP-29, a Concord plane wrap's `p` tag is ephemeral, so there is no + * `#p=me` subscription: each plane is fetched by its derived stream address + * (`authors=[planePk]`, kind 1059). Every joined community's Control Plane is + * subscribed upfront; once a Control Plane folds, [Account.concordSessions] bumps + * its revision and this assembler re-derives to also watch each channel's Chat + * Plane (see [ConcordChannelSubscription]). + */ +class ConcordChannelFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ConcordChannelSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class ConcordChannelSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ConcordChannelQueryState, + since: SincePerRelayMap?, + ): List? { + val account = key.account + val entries = account.concordChannelList.liveCommunities.value + if (entries.isEmpty()) return null + + // Control planes for every joined community, plus channel planes for the + // ones whose Control Plane has already folded. Deriving the channel planes + // is the only account-dependent step; collapsing planes into per-relay + // kind-1059 filters lives in the shared planner. + val subs = ArrayList() + subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries) + // The Guestbook (membership) + next-epoch base-rekey planes. Their stream keys derive from + // the entry alone, so they AUTH on the initial connection; and since the relay now + // re-authenticates on an `auth-required` CLOSED, naming them here no longer starves the + // control/channel REQ the way it did before that fix. + subs += ConcordSubscriptionPlanner.auxiliaryPlaneSubs(entries) + for (entry in entries) { + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value ?: continue + subs += ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) + } + + return ConcordSubscriptionPlanner.relayBasedFilters(subs, since) + } + + override fun id(key: ConcordChannelQueryState) = key.account +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt new file mode 100644 index 0000000000..a8f32ad938 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** One open Concord Channel whose older history the screen wants paged in. */ +class ConcordChannelHistoryQueryState( + val account: Account, + val communityId: String, + val channelId: String, +) + +/** + * Mounts the on-demand **history** pager for whichever Concord Channel screen is open. The live + * [ConcordChannelFilterAssembler] only holds the recent tail the relay serves for each channel plane; + * this pages older messages backward by `until`+`limit` per relay, exactly like the NIP-04 per- + * conversation history ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]). + */ +class ConcordChannelHistoryFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val history = ConcordChannelHistorySubAssembler(client, ::allKeys) + + val group = listOf(history) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +/** + * Pages one Concord Channel's older wraps by `until`+`limit`, per relay, on demand. The per-relay + * cursors live on the channel's [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel] (so + * reopening keeps progress); this binds the single-active [BackwardRelayPager] to the open channel on + * [newSub], builds the kind-1059 channel-plane REQ per armed relay, and forwards relay callbacks into + * the pager. Decryption + landing happen on the normal ingest path (the wraps flow through + * `concordSessions.ingest`); the pager only needs each wrap's `createdAt`. + */ +class ConcordChannelHistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + // Floor at `now` (liveTailSeconds = 0), NOT the DM 7-day tail: the Concord live subscription isn't a + // strict recent-tail (it asks the plane author unbounded and the relay caps the result), so paging + // must walk the WHOLE history from the top to reach "recent but capped" messages. Overlap with the + // live tail is harmless — wraps dedup by id on ingest. + private val pager = BackwardRelayPager("concord.channel.history", liveTailSeconds = 0) + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun id(key: ConcordChannelHistoryQueryState) = ConcordChannelId(key.communityId, key.channelId) + + // This channel's persistent paging cursors, held on its LocalCache ConcordChannel. + private fun cursorsFor(key: ConcordChannelHistoryQueryState) = LocalCache.getOrCreateConcordChannel(id(key)).history + + /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ + private fun relaysFor(key: ConcordChannelHistoryQueryState): Set = + key.account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == key.communityId } + ?.relays + ?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + ?: emptySet() + + /** The channel's derived Chat Plane pubkey — the REQ author. Null until the Control Plane folds it. */ + private fun planePkFor(key: ConcordChannelHistoryQueryState): String? = + key.account.concordSessions + .sessionFor(key.communityId) + ?.channelPlaneAddress(key.channelId) + + override fun updateFilter( + key: ConcordChannelHistoryQueryState, + since: SincePerRelayMap?, + ): List? { + val planePk = planePkFor(key) ?: return emptyList() + val relays = relaysFor(key) + // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked + // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. + val armed = pager.armedRelays(relays) + if (armed.isEmpty()) return emptyList() + return armed.mapNotNull { relay -> + val until = pager.requestedUntilFor(relay) ?: return@mapNotNull null + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = listOf(planePk), + until = until, + limit = pager.pageLimit, + ), + ) + } + } + + /** Steps a single [relay] to its next, older page for the open channel. Driven by its on-screen marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For a channel too short to scroll. */ + fun advanceAll() { + if (pager.advanceAll()) invalidateFilters() + } + + override fun newSub(key: ConcordChannelHistoryQueryState): Subscription { + // Repoint the single-active orchestrator at this channel's cursors and its community relays. + pager.bind(cursorsFor(key), key.account.scope) { relaysFor(key) } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ConcordChannelHistoryQueryState): SubscriptionListener { + // A just-backgrounded channel's subscription can still deliver after the orchestrator rebinds to + // another channel; gate the pager (single-active) on whether it's still bound to THIS channel's + // cursors so a late callback can't move another channel's cursors. newEose runs regardless. + val myCursors = cursorsFor(key) + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEose(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt new file mode 100644 index 0000000000..409d157e4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * Mount on the open Concord Channel screen to keep its backward-history pager bound and armed. The + * channel's Chat Plane pubkey (the REQ author) is only known once the Control Plane folds, so — like + * [ConcordChannelSubscription] — we re-derive the history filter whenever + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] advances. + */ +@Composable +fun ConcordChannelHistorySubscription( + communityId: String, + channelId: String, + dataSource: ConcordChannelHistoryFilterAssembler, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val state = remember(account, communityId, channelId) { ConcordChannelHistoryQueryState(account, communityId, channelId) } + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + LaunchedEffect(revision) { + // A fold can reveal this channel's plane pubkey (the REQ author) for the first time. + dataSource.invalidateFilters() + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt new file mode 100644 index 0000000000..04657724b2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * Mount on any screen that lists the user's joined Concord Channels (the Messages + * tab, the Concord home) to keep their planes live and their folded metadata in + * the LocalCache channel index. + * + * The query state is keyed on the account (stable), so the assembler wouldn't + * re-run its filter derivation on its own when a community folds or the joined set + * changes. We watch [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] + * — bumped on every join/leave and every Control-Plane fold — and on each change: + * 1. refresh the LocalCache [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel] + * rows from the freshly-folded state, so list/chat UIs see the new name, + * channels and membership, then + * 2. invalidate the assembler so a newly-revealed channel plane is subscribed + * (its Chat Plane address is only known after the Control Plane folds). + */ +@Composable +fun ConcordChannelSubscription( + dataSource: ConcordChannelFilterAssembler, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val state = remember(account) { ConcordChannelQueryState(account) } + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + LaunchedEffect(revision) { + // The channel-index refresh (community name/icon, membership, ban pruning) runs + // account-wide from Account on this same revision, so the Messages tab has chips even + // when this screen was never opened. Here we only need to re-derive the subscription + // filters, since a newly-folded channel plane must now be subscribed. + dataSource.invalidateFilters() + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} + +/** + * Always-on account-level preload of every joined community's Control (and folded Chat) planes, + * mounted once high in the logged-in tree ([com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage]) + * — the Concord analog of the always-on account/DM gift-wrap tail + * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription]). + * + * Concord control-plane wraps are addressed to *derived stream keys*, not `#p=self`, so the always-on + * DM tail never picks them up — without this, communities only fold (and thus reveal their channels, + * metadata/icon and membership) while a Concord screen happens to be open. Uses the non-lifecycle + * [KeyDataSourceSubscription] so the planes stay requested app-wide, exactly like DMs, and keeps the + * same [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] watch so a + * fresh fold subscribes its newly-revealed channel planes. + */ +@Composable +fun ConcordChannelPreload(accountViewModel: AccountViewModel) { + val account = accountViewModel.account + val dataSource = accountViewModel.dataSources().concordChannels + val state = remember(account) { ConcordChannelQueryState(account) } + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + LaunchedEffect(revision) { + dataSource.invalidateFilters() + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt new file mode 100644 index 0000000000..f104994923 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList + +/** + * Composition state for the Concord channel message field, mirroring the other + * chat composers (MarmotNewMessageViewModel, ChannelNewMessageViewModel): + * @-mention user suggestions (avatar + name + NIP-05 dropdown) and reply state. + * Sending routes through [Account.sendConcordChannelMessage], which wraps the + * message on the channel plane. The typed text already carries `nostr:npub…` + * mentions inline (rewritten by [UserSuggestionState.replaceCurrentWord]). + */ +@Stable +open class ConcordNewMessageViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var communityId: HexKey? = null + var channelId: HexKey? = null + + val message = TextFieldState() + val replyTo = mutableStateOf(null) + + // How the pending reply is delivered: INLINE stays in the timeline (kind-9 quote), + // MINICHAT pulls it into a thread (kind-1111). Only meaningful while replyTo is set. + val replyMode = mutableStateOf(ReplyMode.INLINE) + + var userSuggestions: UserSuggestionState? = null + var emojiSuggestions: EmojiSuggestionState? = null + + // Encrypted image attachments ride through the shared NIP-17 upload pipeline; a picked image + // opens the upload dialog, which encrypts + uploads and sends an Armada-shaped image message. + var uploadState by mutableStateOf(null) + + open fun init(accountVM: AccountViewModel) { + // Idempotent: the screen calls init() on every recomposition, and it recomposes often while + // paging history. Rebuilding uploadState/suggestion state each time would wipe a picked image + // mid-upload or reset an open @/emoji suggestion list, so only (re)build when the account changes. + if (::accountViewModel.isInitialized && this.accountViewModel === accountVM) return + this.accountViewModel = accountVM + this.account = accountVM.account + + this.userSuggestions?.reset() + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + // Rank people who have posted in this channel first. + priorityPubkeys = { channelAuthors() }, + ) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + fun pickedMedia(media: ImmutableList) { + uploadState?.load(media) + } + + private fun channelAuthors(): Set { + val community = communityId ?: return emptySet() + val channel = channelId ?: return emptySet() + return LocalCache + .getConcordChannelIfExists(ConcordChannelId(community, channel)) + ?.notes + ?.mapNotNull { _, note -> note.author?.pubkeyHex } + ?.toSet() + ?: emptySet() + } + + open fun load( + communityId: HexKey, + channelId: HexKey, + ) { + if (this.communityId != communityId || this.channelId != channelId) { + this.communityId = communityId + this.channelId = channelId + this.message.clearText() + this.replyTo.value = null + } + } + + fun reply(note: Note) { + replyTo.value = note + replyMode.value = ReplyMode.INLINE + } + + /** Reply to [note] directly in a minichat thread (used from the minichat screen / long-press). */ + fun replyInMinichat(note: Note) { + replyTo.value = note + replyMode.value = ReplyMode.MINICHAT + } + + fun toggleReplyMode() { + replyMode.value = if (replyMode.value == ReplyMode.INLINE) ReplyMode.MINICHAT else ReplyMode.INLINE + } + + fun clearReply() { + replyTo.value = null + replyMode.value = ReplyMode.INLINE + } + + fun editFromDraft(draftMessage: String) { + message.setTextAndPlaceCursorAtEnd(draftMessage) + } + + fun canPost() = message.text.isNotBlank() + + fun onMessageChanged() { + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions?.processCurrentWord(lastWord) + emojiSuggestions?.reset() + } else if (lastWord.startsWith(":")) { + emojiSuggestions?.processCurrentWord(lastWord) + userSuggestions?.reset() + } else { + userSuggestions?.reset() + emojiSuggestions?.reset() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { + it.replaceCurrentWord(message, message.currentWord(), item) + it.reset() + } + } + + fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { + emojiSuggestions?.autocompleteInto(message, item) + } + + /** Sends the field's text as a channel message (or a reply). Throws on failure. */ + suspend fun sendPost() { + val community = communityId ?: return + val channel = channelId ?: return + val text = message.text.toString().trim() + if (text.isEmpty()) return + + val parent = replyTo.value + account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) + + message.clearText() + clearReply() + userSuggestions?.reset() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 521b12ae59..a4efd8291f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.nip22Comments.CommentEvent class ChannelFeedFilter( val channel: Channel, @@ -36,12 +37,16 @@ class ChannelFeedFilter( override fun changesFlow() = channel.changesFlow() - // returns the last Note of each user. - override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> account.isAcceptable(it) }) + // A kind-1111 comment is a *minichat* reply — it lives in the thread opened from its + // root message, not as a flat sibling in the main timeline (an inline reply is a normal + // kind-9/42 message and stays). Everything else the channel gathered is a timeline message. + private fun isTimelineMessage(note: Note): Boolean = note.event !is CommentEvent && account.isAcceptable(note) + + override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) }) override fun applyFilter(newItems: Set): Set = newItems - .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } + .filter { channel.notes.containsKey(it.idHex) && isTimelineMessage(it) } .toSet() override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 377b424bbb..995a5367d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -85,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag @@ -141,6 +143,10 @@ open class ChannelNewMessageViewModel : val replyTo = mutableStateOf(null) + // INLINE keeps the reply in the timeline (native reply); MINICHAT sends a kind-1111 + // thread comment that opens as a minichat. Only meaningful while replyTo is set. + val replyMode = mutableStateOf(ReplyMode.INLINE) + var uploadState by mutableStateOf(null) // Stripping failure dialog @@ -221,11 +227,17 @@ open class ChannelNewMessageViewModel : open fun reply(replyNote: Note) { replyTo.value = replyNote + replyMode.value = ReplyMode.INLINE draftTag.newVersion() } + fun toggleReplyMode() { + replyMode.value = if (replyMode.value == ReplyMode.INLINE) ReplyMode.MINICHAT else ReplyMode.INLINE + } + fun clearReply() { replyTo.value = null + replyMode.value = ReplyMode.INLINE draftTag.newVersion() } @@ -419,6 +431,7 @@ open class ChannelNewMessageViewModel : private suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null + val messageText = message.text.toString() val tagger = NewMessageTagger( @@ -439,6 +452,25 @@ open class ChannelNewMessageViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null + // A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the + // channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/ + // hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message, + // not the raw text — so replying in a thread never silently drops any of them. + val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event + if (minichatParent != null) { + return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) { + if (channel is RelayGroupChannel) hTag(channel.groupId.id) + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + geoHash?.let { geohash(it) } + emojis(emojis) + imetas(usedAttachments) + } + } + return when { channel is PublicChatChannel -> { val replyingToEvent = replyTo.value?.toEventHint() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index f23c35fce7..3c19a37df6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder @@ -80,6 +81,10 @@ fun EditFieldRow( DisplayReplyingToNote(it, accountViewModel, nav) { channelScreenModel.clearReply() } + ReplyModeToggle( + mode = channelScreenModel.replyMode.value, + onToggle = { channelScreenModel.toggleReplyMode() }, + ) } channelScreenModel.uploadState?.let { uploading -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index ad8946e39e..33c5682ff7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -20,14 +20,20 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -48,6 +54,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -78,7 +85,10 @@ import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier @@ -113,9 +123,10 @@ fun ChatroomHeaderCompose( // would blank the row. val rendersWithoutEvent = baseNote is RelayGroupServerRoomNote || + baseNote is ConcordServerRoomNote || ( baseNote.event == null && - baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == true + baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true ) if (baseNote.event != null || rendersWithoutEvent) { @@ -159,6 +170,11 @@ private fun ChatroomEntry( return } + if (lastMessage is ConcordServerRoomNote) { + ConcordServerRoomCompose(lastMessage, accountViewModel, nav) + return + } + val marmotGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } if (marmotGroup != null) { MarmotGroupRoomCompose(lastMessage, marmotGroup, accountViewModel, nav) @@ -171,6 +187,12 @@ private fun ChatroomEntry( return } + val concordChannel = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concordChannel != null) { + ConcordRoomCompose(lastMessage, concordChannel, accountViewModel, nav) + return + } + // A NIP-29 group message whose channel gatherer didn't attach (e.g. loaded before its channel // existed, or via a path that skips attach) has no case in the when() below and would blank out. // Resolve the group from its `h` tag + provenance relay and render the group row anyway. @@ -415,6 +437,63 @@ private fun RelayGroupRoomCompose( ) } +@Composable +private fun ConcordRoomCompose( + lastMessage: Note, + baseChannel: ConcordChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? ConcordChannel ?: baseChannel + + val author = lastMessage.author + val noteEvent = lastMessage.event + val lastContent = + if (author != null && noteEvent != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${noteEvent.content.take(200)}" + } else { + // Event-less placeholder row for a just-joined channel with no messages yet. + channel.communityName ?: stringRes(R.string.relay_group_no_messages_yet) + } + + ChannelName( + channelIdHex = channel.channelId.channelId, + channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel), + channelTitle = { modifier -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + Text( + text = channel.toBestDisplayName(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + channel.communityName?.let { communityName -> + Spacer(Modifier.width(6.dp)) + // The chip names the parent community and, when tapped, opens that community's + // channel list — the "chip that opens the Concord Channel" entry point. + ConcordCommunityPill( + communityName = communityName, + onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) }, + ) + } + } + }, + channelLastTime = lastMessage.createdAt(), + channelLastContent = lastContent, + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) }, + ) +} + @Composable private fun RelayGroupServerRoomCompose( row: RelayGroupServerRoomNote, @@ -453,17 +532,87 @@ private fun RelayGroupServerRoomCompose( ) } -/** A small tappable chip naming the relay a channel is hosted on. */ +@Composable +private fun ConcordServerRoomCompose( + row: ConcordServerRoomNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Community name/icon from the folded Control Plane (bumped via the session revision). + val revision by accountViewModel.account.concordSessions.revision + .collectAsStateWithLifecycle() + val metadata = + remember(row.communityId, revision) { + accountViewModel.account.concordSessions + .sessionFor(row.communityId) + ?.state + ?.value + ?.metadata + } + val name = metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(R.string.concord_home_title) + + val author = row.newestMessage?.author + val noteEvent = row.newestMessage?.event + val lastContent = + if (author != null && noteEvent != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${noteEvent.content.take(200)}" + } else { + stringRes(R.string.relay_group_no_messages_yet) + } + + ChannelName( + channelIdHex = row.communityId, + channelPicture = rememberConcordImageModel(metadata?.icon, accountViewModel), + channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.concord_server_label, modifier) }, + channelLastTime = row.newestMessage?.createdAt(), + channelLastContent = lastContent, + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.ConcordServer(row.communityId)) }, + ) +} + +/** + * A tappable chip naming the server/community a Messages row belongs to. Unlike the muted + * note-header [HeaderPill] (PoW/OTS/location markers), this one is a first-class navigation entry + * point, so it keeps the stronger `secondaryContainer` highlight. + */ @Composable private fun RelayNameChip( label: String, onClick: () -> Unit, ) { - HeaderPill( - symbol = MaterialSymbols.Dns, - text = label, - onClick = onClick, - ) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(11.dp), + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 613bb9917f..c6def6253c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.util.replace @@ -28,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -128,7 +131,36 @@ class ChatroomListKnownFeedFilter( } } - return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups).toSet()) + // Concord Channels the user joined (kind 13302 list → folded Control Plane). In INLINE view + // mode each channel is its own Messages row (newest decrypted message — a real Note in + // LocalCache attached to the ConcordChannel — or a placeholder for a just-joined empty + // channel). In GROUPED mode all of a community's channels collapse into one community row + // positioned by that community's newest message. Concord groups by community exactly as + // NIP-29 groups by host relay above; both interleave with the rest of Messages by recency. + val concordChannels = + when (account.settings.concordViewMode.value) { + ConcordViewMode.INLINE -> + account.concordSessions.sessions().flatMap { session -> + val state = session.state.value ?: return@flatMap emptyList() + state.channels.keys.map { channelIdHex -> + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) + channel.newestConcordNote(account) ?: channel.placeholderNote() + } + } + + ConcordViewMode.GROUPED -> + // One row per joined community, carrying the newest message across ALL its channels. + account.concordSessions.sessions().mapNotNull { session -> + val state = session.state.value ?: return@mapNotNull null + val newest = + state.channels.keys + .mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) } + .maxByOrNull { it.createdAt() ?: 0L } + ConcordServerRoomNote(session.entry.id, newest) + } + } + + return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups + concordChannels).toSet()) } override fun updateListWith( @@ -144,11 +176,13 @@ class ChatroomListKnownFeedFilter( // Gets the latest message by room from the new items. val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account) + val newRelevantConcord = filterRelevantConcordMessages(newItems, account) if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty() && - newRelevantRelayGroups.isEmpty() + newRelevantRelayGroups.isEmpty() && + newRelevantConcord.isEmpty() ) { return oldList } @@ -219,6 +253,21 @@ class ChatroomListKnownFeedFilter( } } + newRelevantConcord.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + if (newNotePair.key == oldNote.concordRowKey()) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) { + myNewList = myNewList.replace(oldNote, newNotePair.value) + } + } + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } + } + return sort(myNewList.toSet()).take(1000) } @@ -230,11 +279,13 @@ class ChatroomListKnownFeedFilter( // Gets the latest message by room from the new items. val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account) + val newRelevantConcord = filterRelevantConcordMessages(newItems, account) return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty() && - newRelevantRelayGroups.isEmpty() + newRelevantRelayGroups.isEmpty() && + newRelevantConcord.isEmpty() ) { emptySet() } else { @@ -242,11 +293,57 @@ class ChatroomListKnownFeedFilter( newRelevantPrivateMessages.values + newRelevantPublicMessages.values + newRelevantEphemeralChats.values + - newRelevantRelayGroups.values + newRelevantRelayGroups.values + + newRelevantConcord.values ).toSet() } } + /** + * The row a Concord note belongs to, so [updateListWith] can find and replace it: a per-community + * [ConcordServerRoomNote] (GROUPED), else the note's ConcordChannel gatherer keyed by channel + * (INLINE) or by community (GROUPED), depending on the current view mode. + */ + private fun Note.concordRowKey(): String? = + when (this) { + is ConcordServerRoomNote -> communityId + else -> + inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { ch -> + when (account.settings.concordViewMode.value) { + ConcordViewMode.INLINE -> ch.channelId.toKey() + ConcordViewMode.GROUPED -> ch.channelId.communityId + } + } + } + + /** + * Latest Concord rows from the new items, keyed the same way as [concordRowKey]: by channel in + * INLINE mode (one row per channel) and by community in GROUPED mode (one row per community, + * carried as a [ConcordServerRoomNote]). A Concord message note carries its ConcordChannel as a + * gatherer (attached on decrypt); only message-like rumors are attached as rows — reactions/ + * deletes wire to their target note and never become a room's last message. + */ + private fun filterRelevantConcordMessages( + newItems: Set, + account: Account, + ): MutableMap { + // Newest new message per channel (INLINE) or per community (GROUPED). + val grouped = account.settings.concordViewMode.value == ConcordViewMode.GROUPED + val newestPerKey = mutableMapOf() + newItems.forEach { newNote -> + val channel = newNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return@forEach + if (newNote.event == null || !account.isAcceptable(newNote)) return@forEach + val key = if (grouped) channel.channelId.communityId else channel.channelId.toKey() + val last = newestPerKey[key] + if (last == null || (newNote.createdAt() ?: 0L) > (last.createdAt() ?: 0L)) newestPerKey[key] = newNote + } + if (!grouped) return newestPerKey + // Wrap each community's newest into its collapsed server row. + val result = mutableMapOf() + newestPerKey.forEach { (communityId, note) -> result[communityId] = ConcordServerRoomNote(communityId, note) } + return result + } + private fun filterRelevantPublicMessages( newItems: Set, account: Account, @@ -303,6 +400,13 @@ class ChatroomListKnownFeedFilter( .sortedByDefaultFeedOrder() .firstOrNull() + /** The newest decrypted message loaded in this Concord channel, or null if none yet. */ + private fun ConcordChannel.newestConcordNote(account: Account): Note? = + notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() + /** * The row a relay-group note belongs to in the feed, so [updateListWith] can find and replace it: * a per-relay [RelayGroupServerRoomNote] (GROUPED), a joined group's chat note keyed by group id diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt new file mode 100644 index 0000000000..d57feda237 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A synthetic Messages-list row that collapses ALL of a user's channels in one Concord + * [communityId] into a single entry — the "grouped by community" view mode + * ([com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode.GROUPED]). It is the + * Concord analog of [RelayGroupServerRoomNote] (NIP-29 groups by host relay; Concord groups + * by community). + * + * It is not a real event: [event] stays null and [createdAt] mirrors [newestMessage] (the + * newest decrypted message across that community's channels) so the row interleaves with DMs + * and other chats by recency. Tapping it opens the community's channel list. Exactly one + * instance exists per community — keyed by a stable [idHex] so feed diffing and the LazyColumn + * treat it as the same row across refreshes. + */ +class ConcordServerRoomNote( + val communityId: HexKey, + val newestMessage: Note?, +) : Note(idFor(communityId)) { + override fun createdAt(): Long? = newestMessage?.createdAt() + + companion object { + fun idFor(communityId: HexKey): HexKey = "concordserver-$communityId" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ReplyModeToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ReplyModeToggle.kt new file mode 100644 index 0000000000..6d2a56165a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ReplyModeToggle.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * A small tappable chip shown above a chat composer while a reply is pending: it flips the + * reply between staying inline in the timeline and being pulled aside into a thread + * ("minichat"). Inline is the default; the user opts into the thread. Shared by every chat + * composer (Concord, public chats, relay groups). + */ +@Composable +fun ReplyModeToggle( + mode: ReplyMode, + onToggle: () -> Unit, +) { + val minichat = mode == ReplyMode.MINICHAT + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable(onClick = onToggle), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) { + SymbolIcon( + symbol = if (minichat) MaterialSymbols.Forum else MaterialSymbols.Chat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringRes(if (minichat) R.string.chat_reply_in_thread else R.string.chat_reply_in_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 8f4cdc9eb2..7f2e2ca265 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote @@ -137,11 +138,15 @@ class NotificationFeedFilter( setOf( BadgeAwardEvent.KIND, ChannelMessageEvent.KIND, - // NIP-29 group chat (kind 9). A reply to my group message is a - // kind-9 that p-tags me (see ChannelNewMessageViewModel), fetched - // at startup by filterGroupNotificationsToPubkey. Without kind 9 - // here the acceptableEvent kind gate drops it before the p-tag - // check, so those replies never render on the Notifications tab. + // kind-9 chat message, shared by two features: + // • NIP-29 group chat: a reply to my group message is a kind-9 that p-tags me + // (see ChannelNewMessageViewModel), fetched at startup by + // filterGroupNotificationsToPubkey. + // • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention + // p-tags me; a minichat reply is a kind-1111 CommentEvent below. + // Either way it notifies only when it p-tags me — a plain channel message tags no one + // and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these + // replies before the p-tag check, so they'd never render on the Notifications tab. ChatEvent.KIND, ChatMessageEvent.KIND, ChatMessageEncryptedFileHeaderEvent.KIND, @@ -441,6 +446,40 @@ class NotificationFeedFilter( // Chess events bypass the follow filter — opponents may not be followed val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent + // Concord community messages bypass the follow filter too: a reply/reaction that p-tags me + // in a community I've joined is relevant whether or not I follow that member (fellow members + // usually aren't follows). The p-tag gate below still applies, so only genuine replies / + // reactions / mentions notify — general channel chatter that doesn't tag me never does. + // + // A ConcordChannel gatherer alone isn't enough: notes live in the global LocalCache and keep a + // gatherer reference from every account/community that ever touched them, so require the + // community to be one THIS account has currently joined (mirrors the Marmot check above) — + // otherwise a note from a prior account or a left community would leak onto Notifications. + fun Note?.inJoinedConcordCommunity() = + this?.inGatherers?.any { g -> + g is ConcordChannel && account.concordSessions.sessionFor(g.channelId.communityId) != null + } == true + + val isConcordMessage = it.inJoinedConcordCommunity() + + // A like/repost is NOT itself attached to the channel gatherer — only chat messages/replies are + // (see LocalCache.consumeConcordRumor) — so `inGatherers` never flags it as Concord, and its + // author (a fellow member) usually isn't a follow, so it falls through the follow filter and is + // dropped. Recognize it through its TARGET: a reaction/repost pointing at a message in a + // community I've joined is a Concord reaction, and bypasses the follow filter like a reply does. + // Relevance (does it target ME) is still enforced below by the p-tag gate — a well-formed kind-7 + // p-tags the reacted author (NIP-25), which is exactly what our own ChannelChat.reaction writes. + val isConcordReaction = + (noteEvent is ReactionEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent) && + it.replyTo?.lastOrNull().inJoinedConcordCommunity() + + val isConcord = isConcordMessage || isConcordReaction + + // Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs + // and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so + // Concord reactions do too (only isConcordMessage is gated). + if (isConcordMessage && !showMessages) return false + // Global keeps every event that p-tags the user; Selected (and the // follow/list modes) also applies the per-kind relevance heuristics. val isRawGlobal = followList() is TopFilter.Global @@ -454,11 +493,14 @@ class NotificationFeedFilter( // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && - (isChessEvent || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && + (isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && - (isRawGlobal || tagsAnEventByUser(it, loggedInUserHex)) + // For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/ + // mention targets me directly), so skip the per-kind heuristic — which for a reaction would + // otherwise need my target message already loaded to resolve replyTo. + (isRawGlobal || isConcord || tagsAnEventByUser(it, loggedInUserHex)) } override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 24fa394155..0a1b687fc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.search.SearchScope import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.search.SearchSource @@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull @@ -186,6 +188,15 @@ class SearchBarViewModel( searchTerm .mapLatest { term -> if (term.isBlank()) return@mapLatest null + + // A Concord invite link (…/invite/#) embeds a kind-33301 naddr that + // Nip19Parser would otherwise extract and route to the generic event screen (which + // can't render 33301). Detect the invite first and open the redeem flow — the whole + // URL is carried so the fragment token survives. + if (ConcordActions.parseInviteLink(term) != null) { + return@mapLatest Route.ConcordInvite(term) + } + val parsed = runCatching { Nip19Parser.uriToRoute(term)?.entity } .onFailure { if (it is CancellationException) throw it } @@ -211,9 +222,17 @@ class SearchBarViewModel( } is NAddress -> { - LocalCache.consume(parsed) - routeFor(LocalCache.getOrCreateAddressableNote(parsed.address()), account) - ?: Route.EventRedirect(parsed.aTag()) + // A bare kind-33301 naddr is a Concord invite bundle — not renderable as a + // generic addressable event (and unredeemable without the link's fragment + // token). Send it to the invite flow, which shows a clean "needs the full + // link" state rather than an "unable to render" event screen. + if (parsed.kind == ConcordInviteBundleEvent.KIND) { + Route.ConcordInvite(term) + } else { + LocalCache.consume(parsed) + routeFor(LocalCache.getOrCreateAddressableNote(parsed.address()), account) + ?: Route.EventRedirect(parsed.aTag()) + } } else -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt index edf13e06fe..0f0a0a2d67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -70,6 +71,8 @@ fun MessagesSettingsScreen( ) { val mode by accountViewModel.account.settings.relayGroupViewMode .collectAsStateWithLifecycle() + val concordMode by accountViewModel.account.settings.concordViewMode + .collectAsStateWithLifecycle() Scaffold( topBar = { @@ -87,24 +90,43 @@ fun MessagesSettingsScreen( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), ) - RelayGroupViewModeOption( + ViewModeOption( title = stringRes(R.string.relay_group_view_inline), description = stringRes(R.string.relay_group_view_inline_desc), selected = mode == RelayGroupViewMode.INLINE, onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, ) - RelayGroupViewModeOption( + ViewModeOption( title = stringRes(R.string.relay_group_view_grouped), description = stringRes(R.string.relay_group_view_grouped_desc), selected = mode == RelayGroupViewMode.GROUPED, onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, ) + + Text( + text = stringRes(R.string.concord_view_mode_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + ) + + ViewModeOption( + title = stringRes(R.string.concord_view_inline), + description = stringRes(R.string.concord_view_inline_desc), + selected = concordMode == ConcordViewMode.INLINE, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.INLINE) }, + ) + ViewModeOption( + title = stringRes(R.string.concord_view_grouped), + description = stringRes(R.string.concord_view_grouped_desc), + selected = concordMode == ConcordViewMode.GROUPED, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.GROUPED) }, + ) } } } @Composable -private fun RelayGroupViewModeOption( +private fun ViewModeOption( title: String, description: String, selected: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 51993a25a9..e0e96e33f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -1042,6 +1042,7 @@ private fun FullBleedNoteCompose( makeItShort = false, canPreview = canPreview, quotesLeft = 3, + unPackReply = ReplyRenderType.NONE, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 67fe71c930..5eca19c786 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -306,6 +306,84 @@ Already have a Nostr account? Loading feed Loading account + Redeeming invite… + Could not fetch this invite. The link may be expired or its relays unreachable. + Concord Channels + You haven\'t joined any Concord Channels yet. Create one, or open an invite link. + No channels yet. + Show all channels + Send image + Open channel + Add a banner + New channel + Rename channel + Rename + Channel name + Delete channel + Delete channel? + Delete #%1$s? This can\'t be undone and the channel can\'t be recreated with the same id. + Delete + Where this community\'s encrypted planes are published and read. + %1$s is typing… + %1$s and %2$s are typing… + Several people are typing… + New Concord Channel + Name + About (optional) + Relays + Icon URL (optional) + + %1$d channel + %1$d channels + + + %1$d member + %1$d members + + Create + Invite people + Invite link + Make admin + Remove admin + Ban + Ban from this community? + This member will be added to the community banlist. Their messages will be hidden and their future posts dropped by every member. You can unban them later. + Set a community icon + Relays that store this community\'s encrypted messages. Leave empty to use your own. + Edit community + Save + Members + No owner, admins, or banned members to show yet. + Make admin + Remove admin + Ban + Unban + Remove from community + Remove member? + This rotates the community\'s encryption key so this member can no longer read anything sent afterwards. Everyone else is re-keyed automatically. This can\'t be undone. + Remove + Owner + Admin + Banned + Join community + Concord community invite + Concord invite (open the full invite link to join) + Concord + Concord community display + Inline + By community + Show each channel as its own conversation, mixed in with your chats. + Collapse each community\'s channels into a single row, placed at its newest message. + + In chat + In thread + + Thread + + + %1$d reply + %1$d replies + encrypted legacy Looking for the original message… diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index d5ea6d2a1e..d87970d752 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -230,6 +230,7 @@ class DataDir( val stateFile = File(root, "state.json") val aliasesFile = File(root, "aliases.json") val cashuFile = File(root, "cashu.json") + val concordFile = File(root, "concord.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 90ca163159..00aa30c9ad 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter @@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -58,13 +61,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent @@ -92,6 +98,7 @@ import okhttp3.Dispatcher import okhttp3.OkHttpClient import okhttp3.Request import java.lang.management.ManagementFactory +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** @@ -247,6 +254,41 @@ class Context( startCap = System.getenv("AMY_RELAY_SUB_CAP")?.toIntOrNull()?.coerceIn(1, 100) ?: 16, ).also { client.addConnectionListener(it) } + /** + * Concord plane stream-key AUTH (CORD-01 §4b). 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. `amy concord` verbs register their control + channel stream + * secrets here (scoped to the community's relays, the same scope the plane REQ + * uses) before draining, and [relayAuth] answers a challenge from one of those + * relays with one kind-22242 per stream key — signed locally from the raw derived + * key, never the account, so no user identity is exposed. + */ + private val concordStreamSecrets = ConcurrentHashMap>() + private val concordStreamSigners = ConcurrentHashMap() + + /** Registers raw 32-byte Concord stream [secrets] to answer NIP-42 challenges from [relays]. */ + fun registerConcordStreamKeys( + relays: Set, + secrets: List, + ) { + if (relays.isEmpty() || secrets.isEmpty()) return + val hexes = secrets.map { it.toHexKey() } + for (relay in relays) concordStreamSecrets.getOrPut(relay) { ConcurrentHashMap.newKeySet() }.addAll(hexes) + } + + /** Signs one kind-22242 per Concord stream key registered for [relay] (empty if none). */ + private fun signConcordStreamAuths( + relay: NormalizedRelayUrl, + template: EventTemplate, + ): List = + concordStreamSecrets[relay].orEmpty().mapNotNull { hex -> + runCatching { + concordStreamSigners.getOrPut(hex) { NostrSignerSync(KeyPair(privKey = hex.hexToByteArray())) }.sign(template) + }.getOrNull() + } + /** * NIP-42 responder: answers a relay's AUTH challenge by signing with the * account key, so auth-gated relays serve our reads instead of CLOSing the @@ -254,16 +296,20 @@ class Context( * Only a local key auto-signs — a remote bunker signer is skipped, since a * per-relay remote round-trip during a crawl would stall it (and signing an * auth event with any key still unlocks relays that just want *some* auth). + * Any Concord stream keys registered via [registerConcordStreamKeys] for the + * challenging relay are signed alongside the account AUTH. */ private val relayAuth: RelayAuthenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { _, template -> - if (signer is NostrSignerInternal) { - runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } - } else { - emptyList() - } + signWithAllLoggedInUsers = { relay, template, _ -> + val accountAuth = + if (signer is NostrSignerInternal) { + runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } + } else { + emptyList() + } + accountAuth + signConcordStreamAuths(relay, template) }, ) @@ -596,12 +642,21 @@ class Context( * proven-dead relays from future routing instead of paying the full * [timeoutMs] on them again. Slow-but-connected relays are NOT reported — * only hard connect failures, so a temporarily-busy relay isn't discarded. + * + * With [pendingOnAuthRequired], a relay that refuses the REQ with an + * `auth-required` CLOSED is kept pending rather than treated as terminal: the + * NIP-42 responder answers the challenge and the client re-fires this same + * subscription (`syncFilters`), so the post-auth events are collected instead of + * returning empty. If auth never satisfies it, the relay simply falls through to + * the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served + * only to a connection authenticated as the derived stream key. */ suspend fun drain( filters: Map>, timeoutMs: Long = 8_000, diagnoseSlow: Boolean = false, deadOut: MutableMap? = null, + pendingOnAuthRequired: Boolean = false, ): List> { if (filters.isEmpty()) return emptyList() val eventChannel = Channel>(UNLIMITED) @@ -634,6 +689,9 @@ class Context( relay: NormalizedRelayUrl, forFilters: List?, ) { + // Keep the relay pending on an auth-required refusal: the authenticator answers the + // challenge and re-fires this subscription, so the post-auth events still arrive. + if (pendingOnAuthRequired && MachineReadablePrefix.parse(message) == MachineReadablePrefix.AUTH_REQUIRED) return doneChannel.trySend(relay to "closed:$message") } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index e5f29ecbd0..b627e9d6d9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.commands.AdminCommand import com.vitorpamplona.amethyst.cli.commands.AwaitCommands import com.vitorpamplona.amethyst.cli.commands.BlossomCommands import com.vitorpamplona.amethyst.cli.commands.BunkerCommand +import com.vitorpamplona.amethyst.cli.commands.ConcordCommands import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand import com.vitorpamplona.amethyst.cli.commands.DebitCommands @@ -300,6 +301,7 @@ private suspend fun dispatch(argv: Array): Int { System.err.println("[amy] `wot` is deprecated — use `fof` (follows-of-follows). The computed web of trust is `graperank`.") FofCommand.dispatch(dataDir, tail) } + "concord" -> ConcordCommands.dispatch(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -751,6 +753,15 @@ private fun printUsage() { | | marmot reset [--yes] wipe all local MLS/KeyPackage state (destructive) | + | concord create --name NAME [--about T] [--relays wss://a,wss://b] + | create an encrypted Concord Channel community + | concord list list joined Concord communities + | concord channels COMMUNITY list a community's channels + | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) + | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages + | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord join URL redeem an invite link and save the community + | |Local event store (shared, under `/shared/`): | Backend selected by AMY_STORE: sqlite (default; `shared/events.db`) | or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt new file mode 100644 index 0000000000..7c0577670b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils + +/** `amy concord channels|send|read` — the per-channel chat verbs. */ +object ConcordChannelCommands { + private val HEX64 = Regex("[0-9a-fA-F]{64}") + + suspend fun channels( + dataDir: DataDir, + rest: Array, + ): Int { + val handle = Args(rest).positional(0, "community") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val state = foldState(ctx, sc) + Output.emit( + mapOf( + "name" to state.metadata?.name, + "description" to state.metadata?.description, + "icon" to state.metadata?.icon?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, + "banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, + "channels" to + state.channels.values.map { + mapOf("id" to it.channelIdHex, "name" to it.definition.name, "voice" to it.definition.voice, "private" to it.definition.private) + }, + ), + ) + return 0 + } + } + + suspend fun send( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val text = args.positional(2, "text") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) + val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now()) + val relays = ConcordCommands.relaysFor(ctx, sc) + // A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated. + ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) + val acked = ctx.publish(wrap, relays).filterValues { it }.keys + Output.emit(mapOf("event_id" to wrap.id, "channel" to channelId, "published_to" to acked.map { it.url })) + return 0 + } + } + + suspend fun read( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val limit = args.intFlag("limit", 50) + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) + val relays = ConcordCommands.relaysFor(ctx, sc) + // The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates. + ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + val msgs = ConcordActions.channelMessages(wraps, channel, channelId, sc.rootEpoch).takeLast(limit) + Output.emit( + mapOf( + "channel" to channelId, + "count" to msgs.size, + "messages" to msgs.map { mapOf("id" to it.id, "author" to it.author, "content" to it.content, "created_at" to it.createdAt) }, + ), + ) + return 0 + } + } + + /** Drain the control plane and fold it into the current community state. */ + private suspend fun foldState( + ctx: Context, + sc: StoredCommunity, + ): ConcordCommunityState { + val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val relays = ConcordCommands.relaysFor(ctx, sc) + // The relays gate the plane's kind-1059 behind NIP-42 as the derived stream key — register + // it so the drain's AUTH challenge is answered as the control plane, not the account. + ctx.registerConcordStreamKeys(relays, listOf(controlPlane.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner) + } + + /** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */ + private suspend fun resolve( + ctx: Context, + sc: StoredCommunity, + ref: String, + ): String? { + if (ref == "general" && sc.generalChannelId.isNotBlank()) return sc.generalChannelId + if (HEX64.matches(ref)) return ref + val state = foldState(ctx, sc) + return state.channels.values + .firstOrNull { it.definition.name.equals(ref, ignoreCase = true) } + ?.channelIdHex + ?: state.channels.values + .firstOrNull { it.channelIdHex.startsWith(ref) } + ?.channelIdHex + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt new file mode 100644 index 0000000000..ce377b5992 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy concord …` — create, join, and drive Concord Channels (encrypted, + * serverless communities). Thin assembly over [ConcordActions] (commons) and + * [Context]; secrets persist in `~/.amy//concord.json`. + */ +object ConcordCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "concord", + tail, + "concord ", + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) }, + "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, + "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, + "invite" to { rest -> invite(dataDir, rest) }, + "join" to { rest -> join(dataDir, rest) }, + "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, + "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, + "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, + "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, + "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, + ), + ) + + private suspend fun create( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.requireFlag("name") + val about = args.flag("about") + val relayArg = parseRelays(args.flag("relays")) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val relays = relayArg.ifEmpty { ctx.outboxRelays().map { it.url } } + val community = ConcordActions.createCommunity(ctx.signer, name, TimeUtils.now(), about, relays) + + val publishTo = normalize(relays).ifEmpty { ctx.outboxRelays() } + val acked = mutableSetOf() + for (wrap in community.genesisWraps) acked += ctx.publish(wrap, publishTo).filterValues { it }.keys + + ConcordStore(dataDir.concordFile).upsert( + StoredCommunity( + name = name, + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + generalChannelId = community.generalChannelIdHex, + relays = relays, + ), + ) + + Output.emit( + mapOf( + "community_id" to community.communityIdHex, + "name" to name, + "general_channel_id" to community.generalChannelIdHex, + "published_to" to acked.map { it.url }, + ), + ) + return 0 + } + } + + private fun list( + dataDir: DataDir, + @Suppress("UNUSED_PARAMETER") rest: Array, + ): Int { + val communities = + ConcordStore(dataDir.concordFile).load().map { + mapOf("name" to it.name, "community_id" to it.communityId, "owner" to it.owner, "relays" to it.relays) + } + Output.emit(mapOf("communities" to communities)) + return 0 + } + + private suspend fun invite( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val base = args.flag("base", "https://vector.chat")!! + + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays) + val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays) + val acked = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)).filterValues { it }.keys + + Output.emit( + mapOf( + "url" to minted.url, + "bundle_event_id" to minted.bundleEvent.id, + "link_signer" to minted.linkSignerPubKey, + "published_to" to acked.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun join( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val url = args.positional(0, "url") + val parsed = ConcordActions.parseInviteLink(url) ?: return Output.error("bad_args", "not a valid invite link").let { 2 } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays()) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + val bundle = + wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + ?: return Output.error("not_found", "no valid bundle for this link").let { 1 } + + ConcordStore(dataDir.concordFile).upsert( + StoredCommunity( + name = bundle.name, + communityId = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + relays = bundle.relays, + ), + ) + Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) + return 0 + } + } + + // ---- shared helpers (used by ConcordChannelCommands too) ------------------ + + fun parseRelays(csv: String?): List = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() + + fun normalize(urls: List): Set = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + suspend fun relaysFor( + ctx: Context, + sc: StoredCommunity, + ): Set = normalize(sc.relays).ifEmpty { ctx.outboxRelays() } + + fun notFound(handle: String): Int { + Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") + return 1 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt new file mode 100644 index 0000000000..76cda1589b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** `amy concord roles|role|grant|ban|unban` — Control Plane roles & moderation (CORD-04). */ +object ConcordModCommands { + /** Lists the community's live roles and current banlist. */ + suspend fun roles( + dataDir: DataDir, + rest: Array, + ): Int { + val handle = Args(rest).positional(0, "community") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val (_, editions) = load(ctx, sc) + val state = ConcordCommunityState.fold(editions, sc.owner) + Output.emit( + mapOf( + "roles" to + state.roles.map { (id, r) -> + mapOf("id" to id, "name" to r.name, "position" to r.position, "permissions" to r.permissions) + }, + "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray()).toList(), + ), + ) + return 0 + } + } + + /** Defines a new role: `role PERM...` (perms by name, e.g. BAN KICK). */ + suspend fun defineRole( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val name = args.positional(1, "name") + val position = args.positional(2, "position").toLongOrNull() ?: return Output.error("bad_args", "position must be an integer").let { 2 } + val permBits = args.positional.drop(3).mapNotNull { permByName(it) } + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val (cp, editions) = load(ctx, sc) + val roleId = RandomInstance.bytes(32) + val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) + val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now()) + val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + Output.emit(mapOf("role_id" to roleId.toHexKey(), "name" to name, "position" to position, "published_to" to acked.map { it.url })) + return 0 + } + } + + /** Grants a role to a member: `grant `. */ + suspend fun grant( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val userRef = args.positional(1, "user") + val roleId = args.positional(2, "roleId") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val member = ctx.requireUserHex(userRef) + val (cp, editions) = load(ctx, sc) + val wrap = ConcordModeration.grant(ctx.signer, cp, sc.communityId.hexToByteArray(), member, listOf(roleId), editions, TimeUtils.now()) + val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + Output.emit(mapOf("member" to member, "roles" to listOf(roleId), "published_to" to acked.map { it.url })) + return 0 + } + } + + /** Bans a member: `ban `. */ + suspend fun ban( + dataDir: DataDir, + rest: Array, + ): Int = banOrUnban(dataDir, rest, ban = true) + + /** Unbans a member: `unban `. */ + suspend fun unban( + dataDir: DataDir, + rest: Array, + ): Int = banOrUnban(dataDir, rest, ban = false) + + private suspend fun banOrUnban( + dataDir: DataDir, + rest: Array, + ban: Boolean, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val userRef = args.positional(1, "user") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val member = ctx.requireUserHex(userRef) + val (cp, editions) = load(ctx, sc) + val cid = sc.communityId.hexToByteArray() + val wrap = + if (ban) { + ConcordModeration.ban(ctx.signer, cp, cid, member, editions, TimeUtils.now()) + } else { + ConcordModeration.unban(ctx.signer, cp, cid, member, editions, TimeUtils.now()) + } + val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + Output.emit(mapOf("member" to member, "banned" to ban, "published_to" to acked.map { it.url })) + return 0 + } + } + + /** Drain the control plane and return its key + current editions to chain onto. */ + private suspend fun load( + ctx: Context, + sc: StoredCommunity, + ): Pair> { + val cp = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val relays = ConcordCommands.relaysFor(ctx, sc) + // Concord relays serve the plane's kind-1059 only to a connection AUTHed as the derived + // stream key — register the control key so the drain isn't refused (else the fold is empty). + ctx.registerConcordStreamKeys(relays, listOf(cp.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + return cp to ConcordActions.controlEditions(wraps, cp) + } + + private fun permByName(name: String): Int? = + when (name.uppercase()) { + "MANAGE_ROLES" -> ConcordPermissions.MANAGE_ROLES + "MANAGE_CHANNELS" -> ConcordPermissions.MANAGE_CHANNELS + "MANAGE_METADATA" -> ConcordPermissions.MANAGE_METADATA + "KICK" -> ConcordPermissions.KICK + "BAN" -> ConcordPermissions.BAN + "MANAGE_MESSAGES" -> ConcordPermissions.MANAGE_MESSAGES + "CREATE_INVITE" -> ConcordPermissions.CREATE_INVITE + else -> null + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt new file mode 100644 index 0000000000..95cfb90f22 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.stores + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.SecureFileIO +import java.io.File + +/** + * A joined/created Concord community persisted locally so later `send`/`read`/ + * `channels` runs can re-derive its planes. Holds the community's secrets, so the + * file is written 0600 via [SecureFileIO]. + */ +data class StoredCommunity( + val name: String = "", + val communityId: String = "", + val owner: String = "", + val ownerSalt: String = "", + val root: String = "", + val rootEpoch: Long = 0, + val generalChannelId: String = "", + val relays: List = emptyList(), +) + +/** + * File-backed list of the account's Concord communities at `~/.amy// + * concord.json`. Reloaded per run (no in-process cache), matching Amy's + * stateless-per-invocation model. + */ +class ConcordStore( + private val file: File, +) { + fun load(): List = + if (file.exists()) { + runCatching { Output.mapper.readValue>(file.readText()) }.getOrDefault(emptyList()) + } else { + emptyList() + } + + fun save(list: List) = SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(list)) + + /** Insert or replace by community id, keyed on the self-certifying id. */ + fun upsert(community: StoredCommunity) { + val next = load().filterNot { it.communityId == community.communityId } + community + save(next) + } + + /** Resolve a user-supplied handle: exact name, exact id, or a unique id/name prefix. */ + fun find(handle: String): StoredCommunity? { + val all = load() + return all.firstOrNull { it.name == handle || it.communityId == handle } + ?: all.singleOrNull { it.communityId.startsWith(handle) || it.name.startsWith(handle) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt new file mode 100644 index 0000000000..c975e232e0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -0,0 +1,398 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.Guestbook +import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction +import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.RefoundingBuild +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent + +/** One decrypted, verified Concord channel message projected for display. */ +data class ConcordChatMessage( + val id: HexKey, + val author: HexKey, + val content: String, + val createdAt: Long, + val channelId: HexKey, + val epoch: Long, +) + +/** + * Concord community verbs — pure builders, plane-key derivation, relay-filter + * assembly, and event folding usable from amy CLI, the Android app, and any other + * non-UI consumer. + * + * Like [DmActions], this object never touches the network: create/send builders + * return events to publish, the read side takes already-fetched wraps and folds + * them. The caller (amy `Context`, an Android ViewModel) owns publish/drain and + * persistence of the community's secrets. + */ +object ConcordActions { + // ---- plane key derivation ------------------------------------------------- + + fun controlPlane( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch) + + fun publicChannel( + communityRoot: ByteArray, + channelId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + + /** The Guestbook Plane address for a community at [rootEpoch] — where join/leave motions ride. */ + fun guestbookPlane( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.guestbookPlaneKey(communityRoot, communityId, rootEpoch) + + /** + * The base-rotation rekey address a member watches to receive the *next* epoch's + * Refounding (CORD-06 §2): `base-rekey-pseudonym(current_root, community_id, + * rootEpoch + 1)`. Precomputed from the root the member already holds. + */ + fun nextBaseRekeyPlane( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1) + + // ---- relay filters (what to REQ) ----------------------------------------- + + /** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */ + fun planeFilter(planePubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = listOf(planePubKeyHex)) + + /** Wraps across several plane addresses on one relay: kind-1059 authored by any of them. */ + fun planeFilterFor(planePubKeysHex: List): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = planePubKeysHex) + + /** The public invite bundle for a link signer. */ + fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex)) + + /** Pending direct invites addressed to the given member (indexed by k=3313). */ + fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) + + // ---- community lifecycle -------------------------------------------------- + + /** Creates a community and its genesis editions (see [ConcordCommunityFactory]). */ + suspend fun createCommunity( + ownerSigner: NostrSigner, + name: String, + createdAt: Long, + description: String? = null, + relays: List = emptyList(), + icon: ImagePointer? = null, + ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon) + + /** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */ + fun controlEditions( + wraps: List, + controlPlane: GroupKey, + ): List = + wraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) } + } + + /** Opens the control-plane [wraps] and folds them into the live community state. */ + fun foldCommunity( + wraps: List, + controlPlane: GroupKey, + ownerPubKey: HexKey, + ): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey) + + // ---- channel chat --------------------------------------------------------- + + /** Builds an encrypted-seal channel message wrap to publish on the [channel] plane. */ + suspend fun buildChannelMessage( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + text: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + + /** + * Builds an encrypted-seal channel message wrap carrying one or more encrypted image [imetas] + * (Armada `encryptAttachments` shape) to publish on the [channel] plane. + */ + suspend fun buildChannelImageMessage( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + text: String, + imetas: List, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + + /** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */ + suspend fun buildChannelInlineReply( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + parent: Event, + text: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + + /** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */ + suspend fun buildChannelReply( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + parent: Event, + text: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + + /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ + suspend fun buildChannelReaction( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + target: Event, + reaction: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + + /** + * Builds an **ephemeral** typing heartbeat wrap (kind-23311 rumor, kind-21059 wrap) + * on the [channel] plane. Relays broadcast but never store it; publish every few + * seconds while the user is composing. + */ + suspend fun buildChannelTyping( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + createdAt: Long, + ): Event { + val rumor = ChannelChat.typing(authorSigner.pubKey, channelId, epoch, createdAt) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true, ephemeral = true, createdAt = createdAt) + } + + /** + * Opens the channel [wraps], keeps the kind-9 messages correctly bound to + * [channelId]/[epoch], and returns them oldest-first (createdAt, then id). + */ + fun channelMessages( + wraps: List, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + ): List = + wraps + .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } + .filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) } + .map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) } + .sortedWith(compareBy({ it.createdAt }, { it.id })) + + /** + * Opens the channel [wraps] and returns every validated inner rumor bound to + * [channelId]/[epoch] — messages (kind 9), replies (1111), reactions (7), + * deletes (5), edits, etc. — as typed [Event]s. The caller lands these in a + * store keyed by rumor id so the normal reaction/reply/delete/OTS machinery + * wires up automatically. Deduping is left to that store (rumor ids are stable + * content hashes), so this may return duplicates across mirrored wraps. + */ + fun channelRumors( + wraps: List, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + ): List = + wraps + .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } + .filter { ChannelChat.isBoundTo(it, channelId, epoch) } + + // ---- invites -------------------------------------------------------------- + + /** Builds a [CommunityInvite] from a freshly created (or joined) community's public info. */ + fun inviteFor( + communityIdHex: HexKey, + ownerPubKey: HexKey, + ownerSaltHex: HexKey, + communityRootHex: HexKey, + rootEpoch: Long, + name: String, + relays: List, + ): CommunityInvite = + CommunityInvite( + communityId = communityIdHex, + owner = ownerPubKey, + ownerSalt = ownerSaltHex, + communityRoot = communityRootHex, + rootEpoch = rootEpoch, + relays = relays, + name = name, + ) + + /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ + fun mintInviteLink( + base: String, + invite: CommunityInvite, + createdAt: Long, + relays: List? = null, + ): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays) + + /** Parses a shareable invite URL into its pointer + private fragment. */ + fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) + + /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ + fun openBundle( + bundleEvent: Event, + token: ByteArray, + ): CommunityInvite? = ConcordInviteBundle.parse(bundleEvent, token)?.takeIf { ConcordInviteBundle.validate(it) } + + /** Derives the control plane described by a redeemed [invite] so the joiner can read it. */ + fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch) + + // ---- guestbook (CORD-02 §5) ---------------------------------------------- + + /** + * Builds a self-signed Guestbook JOIN (kind 3306) wrap on the community's + * Guestbook Plane. Membership is off-consensus best-effort presence, but it is + * the member-visible roster a Refounding rotates keys to (CORD-06), so a client + * announces one on create/join to be re-keyed on future removals. + */ + suspend fun buildGuestbookJoin( + memberSigner: NostrSigner, + guestbook: GroupKey, + createdAt: Long, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ): Event { + val rumor = Guestbook.join(memberSigner.pubKey, createdAt, inviteCreator = inviteCreator, inviteLabel = inviteLabel) + return ConcordStreamEnvelope.wrap(rumor, guestbook, memberSigner, encrypted = true, createdAt = createdAt) + } + + /** Opens the guestbook [wraps] into their live membership set (joins minus later leaves). */ + fun guestbookMembers( + wraps: List, + guestbook: GroupKey, + ): Set { + val latest = HashMap() + for (wrap in wraps) { + val rumor = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor ?: continue + val entry = Guestbook.parse(rumor) ?: continue + val prev = latest[entry.member.lowercase()] + if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry + } + return latest.values.filter { it.action == GuestbookAction.JOIN }.mapTo(HashSet()) { it.member.lowercase() } + } + + // ---- refounding / rekey (CORD-06) ---------------------------------------- + + /** + * Builds a whole-community Refounding (CORD-06 §3): the compacted Control Plane + * re-sealed under [newRoot] plus the base-rotation rekey blobs delivering + * [newRoot] to [recipientsXOnly]. Pure — the caller sources the recipient set + * and owns publish + persistence. + */ + suspend fun buildRefounding( + rotatorSigner: NostrSigner, + communityId: HexKey, + priorRoot: ByteArray, + newRoot: ByteArray, + rootEpoch: Long, + priorControlWraps: List, + priorControlKey: GroupKey, + recipientsXOnly: List, + createdAt: Long, + ): RefoundingBuild = + ConcordRefounding.build( + rotatorSigner = rotatorSigner, + communityId = communityId.hexToByteArray(), + priorRoot = priorRoot, + newRoot = newRoot, + rootEpoch = rootEpoch, + priorControlWraps = priorControlWraps, + priorControlKey = priorControlKey, + recipientsXOnly = recipientsXOnly, + createdAt = createdAt, + ) + + /** + * Receives an inbound base rotation for the member behind [recipientSigner]: + * finds the delivered new root across the buffered kind-3303 [wraps], verifying + * scope, epoch and continuity against the [priorRoot] the member holds. Returns + * the new root + rotator (for the caller to authorize) or null if not re-keyed. + */ + suspend fun openBaseRekey( + wraps: List, + baseRekey: GroupKey, + recipientSigner: NostrSigner, + priorRoot: ByteArray, + rootEpoch: Long, + ): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt new file mode 100644 index 0000000000..31c25887cd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer + +/** + * Builds the Control Plane editions (CORD-04) that drive roles and moderation: + * defining a role, granting roles to a member, and banning/unbanning members. + * + * Each is a kind-3308 edition, plaintext-sealed (so the author signature survives + * re-encryption across epochs) and wrapped on the community's Control Plane. The + * caller passes the community's **current** editions so this can chain the next + * version onto the entity's head (`version = head.version + 1`, `prevHash = + * head.hash`) and union the banlist. Authority is enforced at *fold* time by the + * `AuthorityResolver`, not here — an edition whose author doesn't outrank its + * target (or trace to the owner via [citation]) is simply dropped by every client. + * + * The owner needs no [citation]; a delegated moderator must cite the grant they + * act under so the fold can verify the chain terminates at the owner. + */ +object ConcordModeration { + /** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */ + private fun versioning( + current: List, + kind: ControlEntityKind, + entityId: ByteArray, + ): Pair { + val head = current.firstOrNull { it.entityKind == kind && it.entityId.contentEquals(entityId) } + return if (head != null) (head.version + 1) to head.hash else 0L to null + } + + private suspend fun wrap( + actor: NostrSigner, + controlPlane: GroupKey, + kind: ControlEntityKind, + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: String, + createdAt: Long, + citation: AuthorityCitation?, + ): Event { + val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, citation) + return ConcordStreamEnvelope.wrap(rumor, controlPlane, actor, encrypted = false, createdAt = createdAt) + } + + /** + * Defines (or updates) a role. [roleId] is the role's stable 32-byte entity id + * — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it. + */ + suspend fun defineRole( + actor: NostrSigner, + controlPlane: GroupKey, + roleId: ByteArray, + role: RoleEntity, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val (version, prev) = versioning(current, ControlEntityKind.ROLE, roleId) + val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role) + return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation) + } + + /** + * Defines (or updates) a channel (CORD-03/04, `vsk=2`). [channelId] is the channel's stable + * 32-byte entity id — generate one for a new channel and reuse it to rename, flip its + * private/voice flags, or [ChannelEntity.deleted] it (terminal; the id is never reused). + * Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner) tracing to the owner + * via [citation]. + */ + suspend fun defineChannel( + actor: NostrSigner, + controlPlane: GroupKey, + channelId: ByteArray, + channel: ChannelEntity, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val (version, prev) = versioning(current, ControlEntityKind.CHANNEL, channelId) + val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel) + return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) + } + + /** + * Replaces the community metadata (name / icon / description / relays). The + * metadata entity id is the community id itself (as in genesis), so this chains + * the next version onto the metadata head. Honored at fold only when [actor] + * holds MANAGE_METADATA (or is the owner) tracing to the owner via [citation]. + */ + suspend fun editMetadata( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + metadata: MetadataEntity, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val (version, prev) = versioning(current, ControlEntityKind.METADATA, communityId) + val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata) + return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation) + } + + /** Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. */ + suspend fun grant( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + member: HexKey, + roleIds: List, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray()) + val (version, prev) = versioning(current, ControlEntityKind.GRANT, entityId) + val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds)) + return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation) + } + + /** Adds [member] to the banlist (union with the current head). */ + suspend fun ban( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + member: HexKey, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) + member.lowercase(), current, createdAt, citation) + + /** Removes [member] from the banlist. */ + suspend fun unban( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + member: HexKey, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) - member.lowercase(), current, createdAt, citation) + + /** The current banlist union across the head editions (lowercase hex). */ + fun currentBanned( + current: List, + communityId: ByteArray, + ): Set { + val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) + val head = current.firstOrNull { it.entityKind == ControlEntityKind.BANLIST && it.entityId.contentEquals(entityId) } + return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet() + } + + private suspend fun setBanlist( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + banned: Set, + current: List, + createdAt: Long, + citation: AuthorityCitation?, + ): Event { + val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) + val (version, prev) = versioning(current, ControlEntityKind.BANLIST, entityId) + val content = ConcordJson.instance.encodeToString(ListSerializer(String.serializer()), banned.sorted()) + return wrap(actor, controlPlane, ControlEntityKind.BANLIST, entityId, version, prev, content, createdAt, citation) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt new file mode 100644 index 0000000000..71c50cd625 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * One plane to subscribe to: its stream address ([pubKeyHex]) and the relays it + * may live on. [channelId] is null for a community's Control Plane and set for a + * channel's Chat Plane. + */ +data class ConcordPlaneSub( + val channelId: ConcordChannelId?, + val pubKeyHex: String, + val relays: Set, +) + +/** + * Turns the account's joined-communities list into the per-plane relay + * subscriptions that keep Concord Channels live — the Concord analog of NIP-29's + * `RelayGroupMyJoinedGroupsFilterAssembler`. + * + * Because a Concord wrap's `p` tag is ephemeral, there is no single `#p=me` + * subscription; instead each plane is fetched by its derived stream address + * (`authors=[planePk]`). The "warm all joined planes" policy is encoded here: + * every community's Control Plane is subscribed upfront ([controlPlaneSubs]), and + * once its Control Plane folds, every channel's Chat Plane is subscribed + * ([channelPlaneSubs]). + */ +object ConcordSubscriptionPlanner { + /** Control-plane subscriptions for every joined community (known from the entry alone). */ + fun controlPlaneSubs(entries: List): List = + entries.map { e -> + val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch) + ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = normalize(e.relays)) + } + + /** + * The off-channel planes every joined community subscribes to upfront (known + * from the entry alone): the Guestbook Plane (membership motions) and the + * next-epoch base-rekey address (so an inbound Refounding is received live, + * CORD-06). Both are kind-1059 wraps authored by their derived stream address. + */ + fun auxiliaryPlaneSubs(entries: List): List = + entries.flatMap { e -> + val root = e.root.hexToByteArray() + val communityId = e.id.hexToByteArray() + val relays = normalize(e.relays) + val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch) + val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch) + listOf( + ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays), + ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays), + ) + } + + /** Chat-plane subscriptions for every live channel in a folded community [state]. */ + fun channelPlaneSubs( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + ): List { + val root = entry.root.hexToByteArray() + val relays = normalize(entry.relays) + return state.channels.keys.map { channelIdHex -> + val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) + ConcordPlaneSub( + channelId = ConcordChannelId(entry.id, channelIdHex), + pubKeyHex = ch.publicKeyHex, + relays = relays, + ) + } + } + + /** + * Collapses [subs] into a `relay -> [filter]` map ready for a drain/subscribe. + * All plane wraps are kind-1059 authored by the plane address, so each relay + * gets one `{kinds:[1059], authors:[…all plane pks on it…]}` filter. + */ + fun filtersByRelay(subs: List): Map> { + val authorsByRelay = HashMap>() + for (sub in subs) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { ArrayList() }.add(sub.pubKeyHex) + } + return authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors)) } + } + + /** + * Collapses [subs] into one [RelayBasedFilter] per host relay for a live + * subscription: each relay gets a single `{kinds:[1059], authors:[…all plane + * pks on it…], since}` filter, with [since] applied per relay from the EOSE + * map. Returns null when no plane resolves to a relay (nothing to subscribe). + * + * This is the assembler-facing shape (what a `PerUniqueIdEoseManager` returns); + * [filtersByRelay] is the one-shot drain shape (no `since`). + */ + fun relayBasedFilters( + subs: List, + since: SincePerRelayMap?, + ): List? { + val authorsByRelay = HashMap>() + for (sub in subs) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + } + if (authorsByRelay.isEmpty()) return null + + return authorsByRelay.map { (relay, authors) -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + // Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the + // live-only typing heartbeats a relay broadcasts but never stores. + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL), + authors = authors.toList(), + since = since?.get(relay)?.time, + ), + ) + } + } + + private fun normalize(urls: List): Set = urls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt new file mode 100644 index 0000000000..67f090e3fa --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * A Concord channel — one encrypted chat room inside a community — as a + * [Channel] the shared chat UI can render, mirroring NIP-29's + * [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]. + * + * The key difference: a Concord channel has no single relay-signed metadata + * event. Its name/flags come from the community's **folded Control Plane**, its + * decrypted messages are fed in by a subscription that holds the channel key, and + * it is addressed by the derived plane pubkey rather than a host relay — so + * [relays] is the *community's* relay set (a channel may be mirrored on several), + * not a single host. Membership derives from the owner-rooted authority resolver. + */ +@Stable +class ConcordChannel( + val channelId: ConcordChannelId, +) : Channel() { + /** Channel display name from the folded ChannelMetadata, when known. */ + var channelName: String? = null + private set + + var isVoice: Boolean = false + private set + + var isPrivate: Boolean = false + private set + + /** The parent community's display name, from its folded metadata. */ + var communityName: String? = null + private set + + /** The parent community's encrypted-media icon pointer, from its folded metadata (null if unset). */ + var communityIcon: ImagePointer? = null + private set + + /** The parent community's encrypted-media banner pointer, from its folded metadata (null if unset). */ + var communityBanner: ImagePointer? = null + private set + + /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ + var communityRelays: Set = emptySet() + private set + + /** This account's standing in the community (from the authority resolver + banlist). */ + var membership: ConcordMembership = ConcordMembership.MEMBER + private set + + /** + * Per-relay backward-pagination cursors for this channel's history (CORD-03). The live + * subscription only holds the recent tail the relay serves for the channel plane; older + * messages are paged in on demand by `until`+`limit` as the user scrolls, exactly like the + * NIP-04 per-conversation history. Held here so the cursors share the channel's cache lifetime. + */ + val history = RelayLoadingCursors() + + /** + * Refresh this channel's metadata from a freshly-folded community [state] plus + * the community's [relays] and this account's [myPubKey]. Cheap and idempotent + * — called whenever the Control Plane re-folds. + * + * Returns true when a displayed field (channel name, community name/icon, + * membership) actually changed, so the caller can invalidate the channel's + * metadata flow ([updateChannelInfo]) — and thus recompose the Messages-row + * name + community chip — only on a real change, not on every fold tick. + */ + fun updateFrom( + state: ConcordCommunityState, + relays: Set, + myPubKey: HexKey, + ): Boolean { + val def = state.channels[channelId.channelId]?.definition + // Channel fields keep their prior value until the channel edition folds. + val newChannelName = def?.name ?: channelName + val newVoice = def?.voice ?: isVoice + val newPrivate = def?.private ?: isPrivate + val newCommunityName = state.metadata?.name + val newCommunityIcon = state.metadata?.icon + val newCommunityBanner = state.metadata?.banner + val newMembership = ConcordMembership.of(state.authority, myPubKey) + + val changed = + channelName != newChannelName || + isVoice != newVoice || + isPrivate != newPrivate || + communityName != newCommunityName || + communityIcon != newCommunityIcon || + communityBanner != newCommunityBanner || + membership != newMembership + + channelName = newChannelName + isVoice = newVoice + isPrivate = newPrivate + communityName = newCommunityName + communityIcon = newCommunityIcon + communityBanner = newCommunityBanner + communityRelays = relays + membership = newMembership + return changed + } + + /** A Concord channel is reachable on any of its community's relays. */ + override fun relays(): Set = communityRelays + + override fun toBestDisplayName(): String = channelName ?: channelId.channelId + + fun canPost(): Boolean = membership.isMember() + + // Synthetic note representing this channel in the Messages list before any + // message has loaded (so a just-joined channel appears immediately). Mirrors + // RelayGroupChannel.placeholderNote(). + private val placeholderLock = KmpLock() + private var cachedPlaceholder: Note? = null + + fun placeholderNote(): Note = + placeholderLock.withLock { + cachedPlaceholder ?: Note(placeholderIdHex(channelId)).apply { + addGatherer(this@ConcordChannel) + cachedPlaceholder = this + } + } + + companion object { + fun placeholderIdHex(channelId: ConcordChannelId): HexKey = "concord-empty-${channelId.toKey()}" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt new file mode 100644 index 0000000000..830bd0590e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +/** Persistence hook for the last-known kind 13302 event (offline backup). */ +interface ConcordListRepository { + fun concordList(): ConcordCommunityListEvent? + + fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) +} + +/** + * The account's home base for Concord Channels: the kind-13302 + * [ConcordCommunityListEvent] (self-encrypted joined-communities list). This is + * the Concord analog of NIP-29's [RelayGroupListState], but the entries carry the + * community secrets (root/salt/epoch/private-channel keys), so decryption yields + * everything needed to re-derive each plane on any device. + * + * Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and + * [liveServers] (the distinct community ids — the "server" rail). [follow]/ + * [unfollow] read-modify-write the list; the caller publishes the returned event. + */ +class ConcordChannelListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: ConcordListRepository, +) { + // Long-term reference so the GC doesn't collect the note itself. + val concordListNote = cache.getOrCreateAddressableNote(getConcordListAddress()) + + fun getConcordListAddress() = ConcordCommunityListEvent.createAddress(signer.pubKey) + + fun getConcordListFlow(): StateFlow = concordListNote.flow().metadata.stateFlow + + fun getConcordList(): ConcordCommunityListEvent? = concordListNote.event as? ConcordCommunityListEvent + + /** Decrypts the current list (or the offline backup) into its entries. */ + suspend fun entriesWithBackup(note: Note): List { + val event = note.event as? ConcordCommunityListEvent ?: settings.concordList() + return event?.decrypt(signer) ?: emptyList() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveCommunities: StateFlow> = + getConcordListFlow() + .transformLatest { noteState -> + emit(entriesWithBackup(noteState.note)) + }.onStart { + emit(entriesWithBackup(concordListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + /** The distinct community ids across the joined list — the "servers" rail. */ + @OptIn(ExperimentalCoroutinesApi::class) + val liveServers: StateFlow> = + liveCommunities + .transformLatest { entries -> emit(entries.mapTo(mutableSetOf()) { it.id }) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + /** Add or replace [entry] (by community id) and return the new signed list event to publish. */ + suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent { + // Seed from the offline backup as well as the live cache event: the saved list is + // consumed into the cache asynchronously in `init`, so a join that races that load + // would otherwise start from an empty `current` and wipe every prior membership. + val current = entriesWithBackup(concordListNote) + val next = current.filterNot { it.id == entry.id } + entry + return ConcordCommunityListEvent.create(signer, next) + } + + /** Drop the community with [communityId] and return the new list event, or null if none existed. */ + suspend fun unfollow(communityId: String): ConcordCommunityListEvent? { + val current = entriesWithBackup(concordListNote) + if (current.none { it.id == communityId }) return null + val next = current.filterNot { it.id == communityId } + return ConcordCommunityListEvent.create(signer, next) + } + + init { + settings.concordList()?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved concord list") + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { + cache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "ConcordList Collector Start") + getConcordListFlow().collect { noteState -> + (noteState.note.event as? ConcordCommunityListEvent)?.let { + settings.updateConcordListTo(it) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt new file mode 100644 index 0000000000..6a29fd59cf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * A validated inner chat rumor emitted by a session: its parent [communityId] and + * [channelIdHex], plus the typed [rumor] (kind 9 message, 1111 reply, 7 reaction, + * 5 delete, …). The sink lands it in a store keyed by rumor id so the normal + * reaction/reply/delete/OTS/zap machinery wires up automatically. + */ +typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event) -> Unit + +/** + * The result of feeding one wrap to a session's [ConcordCommunitySession.ingest]. It separates + * "was it ours" from "did it change structure", so only structure-changing wraps bump the session + * revision (and thus re-derive plane subscriptions). Landing every chat message as a revision bump + * re-REQs every plane per message and gets the client rate-limited off the relays. + */ +enum class ConcordIngestOutcome { + /** The wrap is not addressed to any plane this session knows. Keep routing it elsewhere. */ + NOT_MINE, + + /** Ours and applied, but nothing the subscription set / folded structure depends on changed — + * a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */ + NON_STRUCTURAL, + + /** Ours and changed structure: a Control-Plane fold (metadata/channels/membership/authority), a + * guestbook membership change, or a buffered base-rekey. Bumps the revision. */ + STRUCTURAL, + ; + + /** True when the wrap belonged to this session (whether or not it changed structure). */ + val claimed get() = this != NOT_MINE +} + +/** + * The live read-model of one joined Concord community, driven by inbound stream + * wraps fed via [ingest]. + * + * It holds the community's [entry] (with secrets), derives the Control Plane + * address up front, and — as control wraps arrive — re-folds the Control Plane + * into [state] (metadata + channels + authority) and re-derives each channel's + * Chat Plane address so subsequent channel wraps decrypt. **It does not store + * messages itself:** each validated chat rumor is handed to [onRumor], whose + * platform-side sink lands it in the shared event store (`LocalCache`) as a real + * Note attached to the channel — so previews, threading, reactions and zaps reuse + * the same machinery every other chat does. Re-emitting is safe because the sink + * dedups by rumor id. This is the stateful counterpart to the pure + * [ConcordActions]/[ConcordPlaneRegistry] helpers. + */ +class ConcordCommunitySession( + val entry: ConcordCommunityListEntry, + val myPubKey: HexKey, + private val onRumor: ConcordRumorSink = { _, _, _ -> }, +) { + private val root = entry.root.hexToByteArray() + private val communityIdBytes = entry.id.hexToByteArray() + + private val controlPlaneKey: GroupKey = ConcordActions.controlPlane(root, communityIdBytes, entry.rootEpoch) + + /** The Guestbook Plane at this epoch — where member join/leave motions ride (CORD-02 §5). */ + private val guestbookKey: GroupKey = ConcordActions.guestbookPlane(root, communityIdBytes, entry.rootEpoch) + + /** + * The base-rotation rekey address for the *next* epoch (CORD-06 §2). A member + * precomputes it from the root they already hold so an inbound Refounding — which + * delivers the next root here — is received live rather than only on re-open. + */ + private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch) + + /** The Control Plane stream address to subscribe to (known from the entry alone). */ + val controlPlaneAddress: HexKey get() = controlPlaneKey.publicKeyHex + + /** The Guestbook Plane stream address to subscribe to (known from the entry alone). */ + val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex + + /** The next-epoch base-rekey stream address to watch for an inbound Refounding. */ + val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex + + private val lock = KmpLock() + + // Deduped inbound wraps. + private val controlWraps = LinkedHashMap() + private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) + private val guestbookWraps = LinkedHashMap() + private val baseRekeyWraps = LinkedHashMap() + + // channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold. + private var channelKeysByAddress = HashMap>() + + private val _state = MutableStateFlow(null) + val state: StateFlow = _state + + private val _members = MutableStateFlow>(emptySet()) + + /** The live Guestbook membership set (self-signed joins minus later leaves). */ + val members: StateFlow> = _members + + private val _observedAuthors = MutableStateFlow>(emptySet()) + + /** + * Everyone whose decrypted channel message this session has seen (lowercase hex). CORD-02 §5: + * "an author seen publishing is observably present, auto-included even if their Join never + * arrived." Most members never send a Guestbook Join, so this is the bulk of the real roster. + */ + val observedAuthors: StateFlow> = _observedAuthors + + private var memberHarvestStarted = false + + /** + * Returns true exactly once — for the caller that should run the one-shot full-history member-roster + * harvest (page every channel's history back to a bounded window so ingest can fold the older posters + * into [observedAuthors]). Idempotent, so re-opening the members screen never re-pages. + */ + fun beginMemberHarvest(): Boolean = + lock.withLock { + if (memberHarvestStarted) { + false + } else { + memberHarvestStarted = true + true + } + } + + // channelIdHex -> (other member pubkey -> createdAt secs of their latest typing heartbeat). + private val typingByChannel = HashMap>() + private val _typing = MutableStateFlow>>(emptyMap()) + + /** + * The latest typing-heartbeat time (createdAt secs) per channel per *other* member (kind 23311, + * CORD-03). The UI applies its own freshness window and shows those still typing. + */ + val typing: StateFlow>> = _typing + + /** + * The community's full membership (lowercase hex): everyone who announced on the Guestbook or + * was seen publishing a channel message ([observedAuthors]), plus the owner and every + * role-holder, minus the banned. Best-effort — a member who joined without a Guestbook motion, + * holds no role, and never posted is invisible (key possession leaves no trace), so this is a + * floor, not a census. + */ + fun allMembers(): Set { + val s = _state.value + val roster = if (s != null) s.authority.roleHolders() + s.ownerPubKey.lowercase() else emptySet() + val banned = s?.authority?.bannedMembers().orEmpty() + return (_members.value + _observedAuthors.value + roster) - banned + } + + /** The size of [allMembers] — the community's true (best-effort) member count. */ + fun memberCount(): Int = allMembers().size + + /** The current Chat Plane addresses to subscribe to, one per folded channel. */ + fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } + + /** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */ + fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key } + + /** The base-rotation rekey [GroupKey] a member opens an inbound Refounding under. */ + fun nextBaseRekeyKey(): GroupKey = nextBaseRekeyKey + + /** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */ + fun pendingBaseRekeyWraps(): List = lock.withLock { baseRekeyWraps.values.toList() } + + /** + * Every stream key whose kind-1059 wraps this session reads: the Control Plane plus + * one per folded channel. These are the identities a NIP-42 relay must see the + * connection authenticate as (kind 22242) to serve the wraps — a Concord wrap is + * authored by the stream key and `p`-tagged to a throwaway ephemeral key, so the + * member is neither author nor recipient and the relay refuses unless we AUTH as the + * stream key itself. + * + * The Guestbook + next-epoch base-rekey planes ([auxStreamKeys]) are intentionally + * NOT included here: mixing them into the shared control/channel AUTH set starved the + * subscription on relays that gate a REQ on stream-key AUTH (control stopped folding, + * channels went empty). They AUTH on their own isolated subscription instead. + */ + fun streamKeys(): List = + lock.withLock { + listOf(controlPlaneKey) + channelKeysByAddress.values.map { it.second } + } + + /** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */ + fun auxStreamKeys(): List = listOf(guestbookKey, nextBaseRekeyKey) + + /** The community's current Control Plane editions — the input a moderation edition chains onto. */ + fun controlEditions(): List = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } + + /** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */ + fun controlPlaneWraps(): List = lock.withLock { controlWraps.values.toList() } + + /** The Control Plane key, for authoring moderation editions. */ + fun controlPlaneKey(): GroupKey = controlPlaneKey + + /** This account's standing, from the current fold. */ + fun membership(): ConcordMembership { + val s = _state.value ?: return ConcordMembership.MEMBER + return ConcordMembership.of(s.authority, myPubKey) + } + + /** + * Ingests a stream [wrap]. If it belongs to this community's Control Plane it + * re-folds; if it belongs to a known channel plane it re-projects that + * channel's messages. The [ConcordIngestOutcome] tells the caller both whether + * the wrap was ours and — crucially — whether it changed *structure* (a fold that + * moves the subscription set / metadata) versus just landing a chat message. Only + * a [ConcordIngestOutcome.STRUCTURAL] result should bump the session revision; + * bumping on every message re-derives every plane's REQ per message and rate-limits + * the relays (they close the plane subs mid-load, so channels appear empty). + */ + fun ingest(wrap: Event): ConcordIngestOutcome { + when (wrap.pubKey) { + controlPlaneAddress -> { + lock.withLock { + if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup + } + refold() + return ConcordIngestOutcome.STRUCTURAL + } + guestbookAddress -> { + lock.withLock { + if (guestbookWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup + } + refoldGuestbook() + return ConcordIngestOutcome.STRUCTURAL + } + nextBaseRekeyAddress -> { + // Buffer only — decrypting a base-rotation blob needs the account signer, so the + // app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. That + // drain runs off the revision tick, so a buffered rekey must bump (rare — a rekey, + // not a message). + lock.withLock { baseRekeyWraps[wrap.id] = wrap } + return ConcordIngestOutcome.STRUCTURAL + } + else -> { + val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE + val (channelIdHex, key) = channelRef + // An ephemeral wrap on a channel plane is a transient signal (typing) — fold it into + // the typing state, never into the stored message buffer or the Note sink. The typing + // UI collects the [typing] StateFlow directly, so this needs no structural revision bump. + if (wrap.kind == ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL) { + ingestTyping(wrap, channelIdHex, key) + return ConcordIngestOutcome.NON_STRUCTURAL + } + val isNew = + lock.withLock { + channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null + } + // Project only the newly-arrived wrap — the buffer's earlier wraps were already + // emitted when they landed, so re-decrypting the whole history on every message + // would be O(history) per message (quadratic over a channel's lifetime). A duplicate + // re-delivery (isNew == false) is a no-op. A full-history sweep (member-roster harvest) + // relies on this staying O(1) per wrap. + if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap)) + // A chat message lands in the feed via [onRumor] → LocalCache, independent of the + // revision; it changes no plane address, so it must NOT bump (see the storm note above). + return ConcordIngestOutcome.NON_STRUCTURAL + } + } + } + + private fun ingestTyping( + wrap: Event, + channelIdHex: HexKey, + key: GroupKey, + ) { + val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return + if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, entry.rootEpoch)) return + val who = rumor.pubKey.lowercase() + if (who == myPubKey.lowercase()) return // never show my own typing back to me + val now = TimeUtils.now() + // Update the map and publish inside the lock so a concurrent heartbeat on another + // channel can't publish an older snapshot last and drop this channel's typers. + lock.withLock { + val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() } + val prev = perChannel[who] + // Clamp a peer's heartbeat to our clock: a wildly future-dated createdAt would never + // fall out of the freshness window below and would block later real heartbeats. + val stamp = minOf(rumor.createdAt, now) + if (prev == null || stamp > prev) perChannel[who] = stamp + perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS } + if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex) + _typing.value = typingByChannel.mapValues { it.value.toMap() } + } + } + + private fun refold() { + // Read the buffer, fold, re-derive channel keys, and publish state atomically under the + // lock so a concurrent control wrap can't publish a smaller fold last. Control editions + // are rare (not per-message), so serializing the fold is cheap. + val newChannels = + lock.withLock { + val wraps = controlWraps.values.toList() + val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner) + + val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first } + val next = HashMap>() + for (channelIdHex in folded.channels.keys) { + val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) + next[key.publicKeyHex] = channelIdHex to key + } + channelKeysByAddress = next + _state.value = folded + folded.channels.keys.filterNot { it in prevChannels } + } + + // Project only channels appearing for the first time. Existing channels' wraps were already + // emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so + // a channel's buffer never pre-dates its first fold) — re-projecting all channels on every + // control edition would be O(channels × history) of redundant decryption. + for (channelIdHex in newChannels) reprojectChannel(channelIdHex) + } + + private fun refoldGuestbook() { + lock.withLock { + val wraps = guestbookWraps.values.toList() + _members.value = ConcordActions.guestbookMembers(wraps, guestbookKey) + } + } + + /** Re-decrypts and re-projects a channel's WHOLE wrap buffer. Only for a re-fold (keys may change). */ + private fun reprojectChannel(channelIdHex: HexKey) { + val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return + val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return + emitChannelRumors(channelIdHex, key, wraps) + } + + /** + * Decrypts + validates [wraps] on [channelIdHex], hands each bound rumor to the sink (which dedups + * by rumor id, so re-emitting is idempotent), and folds their authors into [observedAuthors] — every + * author we decrypt is observably present (CORD-02 §5), a member even without a Guestbook Join. + */ + private fun emitChannelRumors( + channelIdHex: HexKey, + key: GroupKey, + wraps: List, + ) { + val authors = HashSet() + ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor -> + authors.add(rumor.pubKey.lowercase()) + onRumor(entry.id, channelIdHex, rumor) + } + // Every author we just decrypted is observably present (CORD-02 §5), so fold them into the + // roster even if they never posted a Guestbook Join. Atomic so a concurrent add isn't lost. + if (authors.isNotEmpty()) { + _observedAuthors.update { if (it.containsAll(authors)) it else it + authors } + } + } + + companion object { + /** A typing heartbeat is considered current for this many seconds after it's seen. */ + const val TYPING_STALE_SECS = 8L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembership.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembership.kt new file mode 100644 index 0000000000..ca9337671d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembership.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A user's standing in a Concord community, derived from the locally-folded + * Control Plane (the owner-rooted [AuthorityResolver] + banlist). + * + * Unlike NIP-29 (where the relay's signed roster is the truth), Concord + * membership is **key possession**: anyone holding the community key is at least a + * [MEMBER]. Roles layer moderation power on top ([ADMIN]/[OWNER]); the banlist + * removes standing ([BANNED]). [NONE] is only for a user we can't place at all. + */ +enum class ConcordMembership { + /** The community founder — supreme, unremovable. */ + OWNER, + + /** Holds at least one moderation permission (manage roles/channels, kick, ban…). */ + ADMIN, + + /** Holds the community key, no elevated role. */ + MEMBER, + + /** On the community banlist — dropped everywhere. */ + BANNED, + + /** Not placeable in this community. */ + NONE, + ; + + /** True when the user is an active participant (holds the key and isn't banned). */ + fun isMember(): Boolean = this == OWNER || this == ADMIN || this == MEMBER + + /** True when the user may take moderation actions. */ + fun canModerate(): Boolean = this == OWNER || this == ADMIN + + companion object { + private val MOD_BITS = + intArrayOf( + ConcordPermissions.MANAGE_ROLES, + ConcordPermissions.MANAGE_CHANNELS, + ConcordPermissions.MANAGE_METADATA, + ConcordPermissions.KICK, + ConcordPermissions.BAN, + ConcordPermissions.MANAGE_MESSAGES, + ) + + /** + * Classifies [pubKey] against a folded [authority]. [holdsKey] tells us the + * user is a member of this community locally (we joined it / hold its key), + * which distinguishes a plain [MEMBER] from [NONE]. + */ + fun of( + authority: AuthorityResolver, + pubKey: HexKey, + holdsKey: Boolean = true, + ): ConcordMembership { + if (authority.isBanned(pubKey)) return BANNED + if (authority.isOwner(pubKey)) return OWNER + val perms = authority.effectivePermissions(pubKey) + if (MOD_BITS.any { perms.has(it) }) return ADMIN + return if (holdsKey) MEMBER else NONE + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt new file mode 100644 index 0000000000..86699776e7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +/** What kind of plane an address belongs to. */ +enum class ConcordPlaneKind { + CONTROL, + CHANNEL, +} + +/** A known Concord plane: its kind, community, optional channel, and the key to open its wraps. */ +class ConcordPlane( + val kind: ConcordPlaneKind, + val communityId: HexKey, + val channelId: ConcordChannelId?, + val key: GroupKey, +) + +/** The routed result of opening an inbound wrap that belonged to a known plane. */ +class RoutedRumor( + val plane: ConcordPlane, + val opened: OpenedStreamEvent, +) + +/** + * Maps derived plane addresses (`group_key.pk`) to the keys that open them, so an + * inbound kind-1059 wrap can be recognized as Concord traffic and decrypted with + * the right per-plane key. + * + * This is the counterpart to [ConcordChannelListState] on the read path: the list + * gives the community secrets, and this registry expands them into the concrete + * plane addresses to watch. Because a Concord wrap's `p` tag is ephemeral, address + * matching (`wrap.pubkey` → registered plane) is the only way to route it — a + * non-member never registers the address, so they never decrypt. + * + * Control-plane addresses are known from a community entry alone; channel-plane + * addresses become known only after the Control Plane folds ([registerChannels]). + * Thread-safe so the ingest path and UI can share one registry. + */ +class ConcordPlaneRegistry { + private val lock = KmpLock() + private val planes = HashMap() + + /** Registers every joined community's Control Plane address. Idempotent. */ + fun registerControlPlanes(entries: List) = + lock.withLock { + for (e in entries) { + val cp = ConcordKeyDerivation.controlPlaneKey(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch) + planes[cp.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CONTROL, e.id, null, cp) + } + } + + /** Registers the Chat Plane address of every channel in a folded community [state]. */ + fun registerChannels( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + ) = lock.withLock { + val root = entry.root.hexToByteArray() + for (channelIdHex in state.channels.keys) { + val ch = + com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys + .publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) + planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch) + } + } + + /** True if [pubKeyHex] is a Concord plane address this account can open. */ + fun isKnownPlane(pubKeyHex: HexKey): Boolean = lock.withLock { pubKeyHex in planes } + + fun planeFor(pubKeyHex: HexKey): ConcordPlane? = lock.withLock { planes[pubKeyHex] } + + /** + * If [wrap] is a kind-1059 event at a registered plane address, opens it and + * returns the routed rumor; otherwise null (not Concord, or not ours to read). + */ + fun route(wrap: Event): RoutedRumor? { + val plane = planeFor(wrap.pubKey) ?: return null + val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.key) ?: return null + return RoutedRumor(plane, opened) + } + + fun clear() = lock.withLock { planes.clear() } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt new file mode 100644 index 0000000000..8062fbc359 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * The account-scoped owner of the live Concord read-path. It keeps the + * [ConcordSessionRegistry] in step with the joined-communities list and turns + * each community's folds into a single observable tick the app layer watches to + * (re)derive subscription filters and refresh its channel index. + * + * Wiring, per account: + * - Construct once with the account's `liveCommunities` flow, its pubkey, and a + * long-lived [scope]. It self-starts a collector that [ConcordSessionRegistry.sync]s + * on every list change and, for each **new** session, watches its + * [ConcordCommunitySession.state] so a fold bumps [revision]. + * - Feed every inbound kind-1059 wrap through [ingest]; a Concord plane wrap is + * applied (control → re-fold, channel → re-project) and returns true, so the + * caller can stop treating it as a NIP-59 DM. + * - Read [subscribeAddresses] to build the `authors` set for the kind-1059 + * subscription; re-read it whenever [revision] advances (a fold reveals new + * channel planes to watch). + * + * Everything platform-specific (the LocalCache channel index, the actual REQ + * mounting) stays in the app layer, which reacts to [revision]; this class holds + * no Android/UI dependency so it stays unit-testable. + */ +class ConcordSessionManager( + private val communities: StateFlow>, + private val myPubKey: HexKey, + private val scope: CoroutineScope, + private val onRumor: ConcordRumorSink = { _, _, _ -> }, +) { + val registry = ConcordSessionRegistry(onRumor) + + private val _revision = MutableStateFlow(0) + + /** Monotonic counter bumped whenever the joined set or any community's fold changes. */ + val revision: StateFlow = _revision + + private val lock = KmpLock() + private val stateWatchers = HashMap() // communityId -> state collector + + init { + scope.launch { + communities.collect { entries -> onCommunitiesChanged(entries) } + } + } + + private fun onCommunitiesChanged(entries: List) { + val created = registry.sync(entries, myPubKey) + val wantedIds = entries.mapTo(HashSet()) { it.id } + + lock.withLock { + // Cancel watchers for communities we've left. + val departed = stateWatchers.keys.filterNot { it in wantedIds } + for (id in departed) stateWatchers.remove(id)?.cancel() + + // Watch each newly-created session so its folds bump the revision. A Refounding + // rebuilds a still-joined community's session in place (same id, new root/epoch), + // so it comes back in `created` while its old watcher is still running — cancel + // that stale collector before replacing the map entry, or every Refounding leaks + // a coroutine holding a dead session and bumping the revision forever. + for (id in created) { + val session = registry.sessionFor(id) ?: continue + stateWatchers.remove(id)?.cancel() + stateWatchers[id] = + scope.launch { + session.state.collect { bumpRevision() } + } + } + } + bumpRevision() + } + + private fun bumpRevision() { + // Called from the communities collector, every per-session state watcher, and the + // ingest path — different coroutines/dispatchers — so the increment must be atomic + // or concurrent bumps are lost. + _revision.update { it + 1 } + } + + /** The `authors` set (control + known channel planes) for the kind-1059 subscription. */ + fun subscribeAddresses(): Set = registry.subscribeAddresses() + + /** + * The stream secret keys that must answer a NIP-42 AUTH challenge from [relay]: + * every plane of every joined community whose relays include [relay] — the Control + * Plane + folded channels ([ConcordCommunitySession.streamKeys]) plus the Guestbook + * and next-epoch base-rekey planes ([ConcordCommunitySession.auxStreamKeys]). Concord + * relays serve a plane's kind-1059 wraps only to a connection authenticated as that + * stream key, so the relay-auth layer signs a kind-22242 with each of these (locally, + * never the user's signer) — without them the plane REQ is refused. Since the relay + * re-authenticates on an `auth-required` CLOSED, a key revealed only after the Control + * Plane folds (a channel) is picked up on the retry; the aux keys derive from the entry + * alone, so they authenticate on the initial connection. + */ + fun streamAuthSecretsFor(relay: NormalizedRelayUrl): List { + val out = ArrayList() + for (session in registry.sessions()) { + val relays = session.entry.relays.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relay in relays) { + session.streamKeys().forEach { out.add(it.secretKey) } + session.auxStreamKeys().forEach { out.add(it.secretKey) } + } + } + return out + } + + /** + * Route an inbound stream wrap; true if it was a Concord plane wrap we applied. Only a wrap that + * changed community *structure* (a fold, a membership/rekey change — not a plain chat message) + * bumps the revision: bumping per message re-derives every plane subscription per message and + * gets the client rate-limited off the relays (which then close the plane subs mid-load). Chat + * messages still reach the feed via the rumor sink → LocalCache, independent of the revision. + */ + fun ingest(wrap: Event): Boolean { + val outcome = registry.ingest(wrap) + if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision() + return outcome.claimed + } + + fun sessions() = registry.sessions() + + fun sessionFor(communityId: HexKey) = registry.sessionFor(communityId) + + fun destroy() { + lock.withLock { + stateWatchers.values.forEach { it.cancel() } + stateWatchers.clear() + } + registry.clear() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt new file mode 100644 index 0000000000..800b299b4f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The account-wide coordinator for every joined Concord community: it holds one + * live [ConcordCommunitySession] per community id and fans inbound stream wraps + * out to whichever session owns them. This is the read-path analog of + * [ConcordChannelListState] on the write path — the list yields the joined + * [ConcordCommunityListEntry] set, this expands each into a folding read-model. + * + * The app layer drives it from two directions: + * - [sync] whenever the joined list changes (from `liveCommunities`), which + * creates sessions for new communities and drops sessions for departed ones + * while **preserving** the already-folded state of the ones that remain. + * - [ingest] for every inbound kind-1059 wrap, which routes it to the matching + * session (control plane → re-fold; channel plane → re-project messages). + * + * [subscribeAddresses] returns the union of every session's control- and + * channel-plane addresses — exactly the `authors` set a subscription must watch + * for kind-1059 wraps. Thread-safe: the ingest path and UI share one instance. + */ +class ConcordSessionRegistry( + private val onRumor: ConcordRumorSink = { _, _, _ -> }, +) { + private val lock = KmpLock() + + // communityId -> live folding session. Insertion-ordered for stable iteration. + private val sessions = LinkedHashMap() + + /** + * Reconcile the held sessions with the current joined [entries]. Sessions for + * communities still present are kept as-is (their folded state survives); + * sessions for communities no longer joined are dropped; new communities get a + * fresh session. Returns the set of community ids whose sessions were created. + */ + fun sync( + entries: List, + myPubKey: HexKey, + ): Set = + lock.withLock { + val wanted = entries.associateBy { it.id } + // Drop sessions for communities we've left. + sessions.keys.retainAll(wanted.keys) + // Add sessions for newly-joined communities, and rebuild a session whose access + // material changed under it — a Refounding rotates the community_root and bumps + // the epoch (CORD-06), so the persisted entry now describes a different set of + // planes; the session is a pure function of its entry, so we recreate it to + // re-derive every address and re-fold under the new root. + val created = mutableSetOf() + for ((id, entry) in wanted) { + val existing = sessions[id] + if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) { + sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor) + created += id + } + } + created + } + + fun sessionFor(communityId: HexKey): ConcordCommunitySession? = lock.withLock { sessions[communityId] } + + fun sessions(): List = lock.withLock { sessions.values.toList() } + + /** The union of control- and channel-plane addresses across all sessions to subscribe to. */ + fun subscribeAddresses(): Set = + lock.withLock { + val out = HashSet() + for (session in sessions.values) { + out += session.controlPlaneAddress + out += session.channelAddresses() + } + out + } + + /** + * Routes an inbound stream [wrap] to whichever session recognizes it, returning that session's + * [ConcordIngestOutcome] (or [ConcordIngestOutcome.NOT_MINE] if none claim it). A wrap belongs to + * at most one plane, so the first accepting session wins. + */ + fun ingest(wrap: Event): ConcordIngestOutcome { + val snapshot = lock.withLock { sessions.values.toList() } + for (session in snapshot) { + val outcome = session.ingest(wrap) + if (outcome != ConcordIngestOutcome.NOT_MINE) return outcome + } + return ConcordIngestOutcome.NOT_MINE + } + + fun clear() = lock.withLock { sessions.clear() } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt new file mode 100644 index 0000000000..a243b828b7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +/** + * How joined Concord Channels surface in the Messages inbox (mirrors + * `RelayGroupViewMode`). + */ +enum class ConcordViewMode { + /** One inbox row per channel across all joined communities. */ + INLINE, + + /** One inbox row per community, collapsing its channels behind a drill-down. */ + GROUPED, + ; + + companion object { + val DEFAULT = INLINE + + fun fromName(name: String?): ConcordViewMode = entries.firstOrNull { it.name == name } ?: DEFAULT + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index aca5f827a7..b19a86f8c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.richtext +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.util.isValidUrl @@ -375,6 +376,12 @@ class RichTextParser { } if (urls.withScheme.contains(word)) { + // A Concord invite link is a plain https URL, so it would otherwise render as a bare + // link. Cheap substring gates keep the base64/bech32 parse off the hot path for + // ordinary URLs; only `…/invite/…#…` shapes are actually decoded. + if (word.contains("/invite/") && word.contains('#') && ConcordActions.parseInviteLink(word) != null) { + return ConcordInviteLinkSegment(word) + } parseNowhereLink(word)?.let { return it } return LinkSegment(word) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 3e8a2a75f6..d5f5890fce 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -164,6 +164,16 @@ class RelayGroupLinkSegment( segment: String, ) : Segment(segment) +/** + * A Concord invite link (`…/invite/#`). Rendered as a tappable + * chip that opens the redeem flow; [segmentText] is the whole literal (including + * the URL fragment, which carries the unlock token and never hits a server). + */ +@Immutable +class ConcordInviteLinkSegment( + segment: String, +) : Segment(segment) + @Immutable class BlossomUriSegment( segment: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt new file mode 100644 index 0000000000..65106e421a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +/** + * How a chat reply is delivered, chosen by the user at send time. + * + * - [INLINE] — a normal chat message that references its parent and stays in the + * main timeline (the chat protocol's native reply: NIP-C7 kind-9 `q` quote, + * kind-42 reply, kind-9 `+h` reply, kind-14 reply). The default. + * - [MINICHAT] — a kind-1111 NIP-22 comment rooted at the parent message, pulled + * out of the timeline into a "chat within a chat" (minichat) opened from the + * parent. Wire-compatible with Soapbox Armada's thread replies. + */ +enum class ReplyMode { + INLINE, + MINICHAT, +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt new file mode 100644 index 0000000000..15c73f51d0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConcordActionsTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun createFoldSendReadRoundTrip() = + runTest { + val community = ConcordActions.createCommunity(owner, "Test Server", createdAt = 1L, relays = listOf("wss://r.example")) + + // Fold genesis -> live state + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + assertEquals("Test Server", state.metadata?.name) + assertTrue(state.channels.containsKey(community.generalChannelIdHex)) + + // Send + read a channel message + val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + val wrap = ConcordActions.buildChannelMessage(owner, channel, community.generalChannelIdHex, community.rootEpoch, "hello world", createdAt = 2L) + val msgs = ConcordActions.channelMessages(listOf(wrap), channel, community.generalChannelIdHex, community.rootEpoch) + assertEquals(1, msgs.size) + assertEquals("hello world", msgs[0].content) + assertEquals(owner.pubKey, msgs[0].author) + } + + @Test + fun inviteMintParseAndOpen() = + runTest { + val community = ConcordActions.createCommunity(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val invite = + ConcordActions.inviteFor( + communityIdHex = community.communityIdHex, + ownerPubKey = community.ownerPubKey, + ownerSaltHex = community.ownerSalt.toHex(), + communityRootHex = community.communityRoot.toHex(), + rootEpoch = community.rootEpoch, + name = "Nostrichs", + relays = listOf("wss://r.example"), + ) + val minted = ConcordActions.mintInviteLink("https://vector.chat", invite, createdAt = 1L) + + val parsed = ConcordActions.parseInviteLink(minted.url) + assertNotNull(parsed) + val opened = ConcordActions.openBundle(minted.bundleEvent, parsed.fragment.token) + assertNotNull(opened) + assertEquals(community.communityIdHex, opened.communityId) + + // The joiner can derive the control plane and read the genesis. + val controlPlane = ConcordActions.controlPlaneFor(opened) + val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.owner) + assertEquals("Nostrichs", state.metadata?.name) + } + + @Test + fun guestbookJoinFoldsIntoMembership() = + runTest { + val community = ConcordActions.createCommunity(owner, "Test", createdAt = 1L, relays = listOf("wss://r.example")) + val alice = NostrSignerInternal(KeyPair()) + val bob = NostrSignerInternal(KeyPair()) + val guestbook = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch) + + val joins = + listOf( + ConcordActions.buildGuestbookJoin(alice, guestbook, createdAt = 2L), + ConcordActions.buildGuestbookJoin(bob, guestbook, createdAt = 3L), + ) + val members = ConcordActions.guestbookMembers(joins, guestbook) + assertEquals(setOf(alice.pubKey.lowercase(), bob.pubKey.lowercase()), members) + } + + @Test + fun refoundingReKeysRetainedAndSeversRemoved() = + runTest { + val community = ConcordActions.createCommunity(owner, "Test", createdAt = 1L, relays = listOf("wss://r.example")) + val alice = NostrSignerInternal(KeyPair()) // retained + val carol = NostrSignerInternal(KeyPair()) // removed + + val newRoot = ByteArray(32) { 0x33 } + val build = + ConcordActions.buildRefounding( + rotatorSigner = owner, + communityId = community.communityIdHex, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = community.controlPlane, + recipientsXOnly = listOf(owner.pubKey, alice.pubKey), + createdAt = 5L, + ) + + val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch) + + val aliceGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, alice, community.communityRoot, community.rootEpoch) + val carolGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, carol, community.communityRoot, community.rootEpoch) + assertNotNull(aliceGot) + assertEquals(community.rootEpoch + 1, aliceGot.newEpoch) + assertTrue(carolGot == null) + + // The compacted Control Plane folds identically under the new root. + val newControl = ConcordActions.controlPlane(aliceGot.newRoot, community.communityId, aliceGot.newEpoch) + val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey) + assertEquals("Test", state.metadata?.name) + assertTrue(state.channels.isNotEmpty()) + } + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt new file mode 100644 index 0000000000..1b91d1c85a --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordModerationTest { + private val owner = NostrSignerInternal(KeyPair()) + private val admin = NostrSignerInternal(KeyPair()) + private val troll = NostrSignerInternal(KeyPair()) + + @Test + fun ownerDefinesRoleGrantsItAndBans() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val communityId = community.communityId + + // Accumulate the community's editions as we publish more. + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: com.vitorpamplona.quartz.nip01Core.core.Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + + // Owner defines an "Admin" role (position 1) that can BAN and KICK. + val roleId = ByteArray(32) { (it + 1).toByte() } + val roleIdHex = roleId.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + val adminRole = + RoleEntity( + name = "Admin", + position = 1, + permissions = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.KICK).toWire(), + ) + add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L)) + + // Owner grants that role to the admin user. + add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L)) + + // Owner bans the troll. + add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L)) + + val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey) + + // The role exists, the admin holds BAN + the role id, and the troll is banned. + assertTrue(state.roles.containsKey(roleIdHex)) + assertTrue(state.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN)) + assertTrue(roleIdHex in state.authority.rolesOf(admin.pubKey)) + assertTrue(state.authority.isBanned(troll.pubKey)) + assertFalse(state.authority.isBanned(admin.pubKey)) + + // Revoking (an empty grant, as "Remove admin" does) strips the role and its permissions. + add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L)) + val demoted = ConcordCommunityState.fold(editions, community.ownerPubKey) + assertFalse(demoted.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN)) + assertTrue(demoted.authority.rolesOf(admin.pubKey).isEmpty()) + + // Unbanning the troll clears the flag (version chains onto the ban). + add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L)) + val healed = ConcordCommunityState.fold(editions, community.ownerPubKey) + assertFalse(healed.authority.isBanned(troll.pubKey)) + + // A grant forged by the troll (who outranks nobody) is dropped by the fold. + val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L) + val forgedEditions: List = editions + ConcordActions.controlEditions(listOf(forged), cp) + val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey) + assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt new file mode 100644 index 0000000000..bb43bb818e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.amethyst.commons.relays.MutableTime +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordSubscriptionPlannerTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun controlAndChannelSubsMatchDerivedAddresses() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + // Control-plane sub address must equal the derived control plane pk. + val controlSubs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)) + assertEquals(1, controlSubs.size) + assertEquals(community.controlPlane.publicKeyHex, controlSubs[0].pubKeyHex) + assertTrue(controlSubs[0].channelId == null) + + // Channel-plane subs cover the folded #general channel. + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val channelSubs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) + val general = channelSubs.firstOrNull { it.channelId?.channelId == community.generalChannelIdHex } + assertTrue(general != null) + assertEquals( + ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch).publicKeyHex, + general.pubKeyHex, + ) + + // filtersByRelay collapses to one kind-1059 author filter per relay. + val filters = ConcordSubscriptionPlanner.filtersByRelay(controlSubs + channelSubs) + assertEquals(1, filters.size) // single relay + val filter = filters.values.first().first() + assertEquals(listOf(1059), filter.kinds) + assertTrue(filter.authors!!.contains(community.controlPlane.publicKeyHex)) + assertTrue(filter.authors!!.contains(general.pubKeyHex)) + } + + @Test + fun relayBasedFiltersCollapsePerRelayAndApplySince() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val subs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)) + val relay = RelayUrlNormalizer.normalizeOrNull("wss://r.example")!! + + // One filter for the single relay, carrying the derived since. Live subscriptions ask for + // both the stored wrap (1059) and the ephemeral wrap (21059) that carries typing heartbeats. + val filters = ConcordSubscriptionPlanner.relayBasedFilters(subs, mutableMapOf(relay to MutableTime(1234L)))!! + assertEquals(1, filters.size) + assertEquals(relay, filters[0].relay) + assertEquals(listOf(1059, 21059), filters[0].filter.kinds) + assertEquals(1234L, filters[0].filter.since) + assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.publicKeyHex)) + + // No planes resolve to a relay -> nothing to subscribe. + assertNull(ConcordSubscriptionPlanner.relayBasedFilters(emptyList(), null)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt new file mode 100644 index 0000000000..0cb8152a95 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ConcordCommunitySessionTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun ingestsControlThenChannelWrapsIntoFlows() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + val captured = mutableListOf>() + val session = ConcordCommunitySession(entry, owner.pubKey) { communityId, channelIdHex, rumor -> captured += Triple(communityId, channelIdHex, rumor) } + assertEquals(community.controlPlane.publicKeyHex, session.controlPlaneAddress) + + // Feed the genesis control wraps → state folds, channels + membership resolve. A fold is + // STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision. + community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(it)) } + val state = session.state.value + assertEquals("Nostrichs", state?.metadata?.name) + assertTrue(state!!.channels.containsKey(community.generalChannelIdHex)) + assertEquals(ConcordMembership.OWNER, session.membership()) + + // The #general channel plane is now a known address. + val general = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + assertTrue(session.channelAddresses().contains(general.publicKeyHex)) + + // A channel message wrap decrypts and is emitted to the sink for #general. + val msgWrap = ConcordActions.buildChannelMessage(owner, general, community.generalChannelIdHex, community.rootEpoch, "gm all", 2L) + // A chat message lands in the feed but is NON_STRUCTURAL: it must never bump the revision + // (per-message re-subscription is what rate-limited the plane REQs and emptied channels). + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(msgWrap)) + val general9 = captured.filter { it.second == community.generalChannelIdHex && it.third.content == "gm all" } + assertEquals(1, general9.size) + assertEquals(community.communityIdHex, general9[0].first) + assertEquals(owner.pubKey, general9[0].third.pubKey) + val message = general9[0].third + + // A reaction to that message decrypts as a kind-7 bound to the channel, e-tagging the target. + val reactionWrap = ConcordActions.buildChannelReaction(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "🤙", 3L) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(reactionWrap)) + val reaction = captured.map { it.third }.first { it.kind == 7 } + assertEquals("🤙", reaction.content) + assertEquals(message.id, reaction.tags.first { it[0] == "e" }[1]) + + // A reply decrypts as a kind-1111 NIP-22 thread comment: uppercase `E` at the thread + // root and lowercase `e` at the immediate parent (both the message here), still bound + // to the channel so it groups into the message's thread — the shape Armada threads. + val replyWrap = ConcordActions.buildChannelReply(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "gm back", 4L) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(replyWrap)) + val reply = captured.map { it.third }.first { it.content == "gm back" } + assertEquals(1111, reply.kind) + assertEquals(message.id, reply.tags.first { it[0] == "E" }[1]) + assertEquals(message.id, reply.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(reply, community.generalChannelIdHex, community.rootEpoch)) + + // Each incoming wrap is projected to the sink exactly ONCE (message + reaction + reply = 3), + // never by re-decrypting the whole channel buffer per message — the O(1) ingest path that + // keeps a full-history member harvest from being O(n²). + assertEquals(3, captured.size) + // Both channel authors observed (owner posted all three) — folded into the roster. + assertEquals(setOf(owner.pubKey.lowercase()), session.observedAuthors.value) + + // A stray wrap from a different community is ignored. + val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) + assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(outsider.genesisWraps.first())) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt new file mode 100644 index 0000000000..770e7ed98e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConcordMembershipTest { + private val owner = "0f".repeat(32) + private val admin = "a1".repeat(32) + private val member = "b2".repeat(32) + private val banned = "c3".repeat(32) + private val stranger = "d4".repeat(32) + private val adminRole = "11".repeat(32) + + private fun ed( + kind: ControlEntityKind, + eid: String, + content: String, + author: String = owner, + ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0) + + private val authority = + AuthorityResolver.resolve( + listOf( + ed(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), // KICK|BAN|MANAGE_ROLES + ed(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$admin","role_ids":["$adminRole"]}"""), + ed(ControlEntityKind.BANLIST, "44".repeat(32), """["$banned"]"""), + ), + owner, + ) + + @Test + fun classifiesStandingFromAuthority() { + assertEquals(ConcordMembership.OWNER, ConcordMembership.of(authority, owner)) + assertEquals(ConcordMembership.ADMIN, ConcordMembership.of(authority, admin)) + assertEquals(ConcordMembership.MEMBER, ConcordMembership.of(authority, member)) + assertEquals(ConcordMembership.BANNED, ConcordMembership.of(authority, banned)) + // A user we don't hold the key for reads as NONE. + assertEquals(ConcordMembership.NONE, ConcordMembership.of(authority, stranger, holdsKey = false)) + } + + @Test + fun capabilityHelpers() { + assertEquals(true, ConcordMembership.OWNER.canModerate()) + assertEquals(true, ConcordMembership.ADMIN.canModerate()) + assertEquals(false, ConcordMembership.MEMBER.canModerate()) + assertEquals(true, ConcordMembership.MEMBER.isMember()) + assertEquals(false, ConcordMembership.BANNED.isMember()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt new file mode 100644 index 0000000000..6f395de18e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordPlaneRegistryTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun routesControlAndChannelWrapsAndRejectsOutsiders() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + val registry = ConcordPlaneRegistry() + registry.registerControlPlanes(listOf(entry)) + + // A genesis control wrap routes to the CONTROL plane. + val controlWrap = community.genesisWraps.first() + assertTrue(registry.isKnownPlane(controlWrap.pubKey)) + val routedControl = registry.route(controlWrap) + assertNotNull(routedControl) + assertEquals(ConcordPlaneKind.CONTROL, routedControl.plane.kind) + assertEquals(community.communityIdHex, routedControl.plane.communityId) + + // After folding + registering channels, a channel message routes to CHANNEL. + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + registry.registerChannels(entry, state) + + val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + val msgWrap = ConcordActions.buildChannelMessage(owner, channel, community.generalChannelIdHex, community.rootEpoch, "gm", 2L) + val routedMsg = registry.route(msgWrap) + assertNotNull(routedMsg) + assertEquals(ConcordPlaneKind.CHANNEL, routedMsg.plane.kind) + assertEquals(community.generalChannelIdHex, routedMsg.plane.channelId?.channelId) + assertEquals(ChatEvent.KIND, routedMsg.opened.rumor.kind) + assertEquals("gm", routedMsg.opened.rumor.content) + + // A wrap from an unrelated plane (different community) is not ours. + val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) + assertNull(registry.route(outsider.genesisWraps.first())) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt new file mode 100644 index 0000000000..58b0c55253 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordSessionManagerTest { + private val owner = NostrSignerInternal(KeyPair()) + + private fun entryFor( + community: NewConcordCommunity, + name: String, + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = name, + ) + + @Test + fun syncsFromFlowFoldsOnIngestAndAdvancesRevision() = + runTest { + val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val communities = MutableStateFlow(listOf(entryFor(alpha, "Alpha"))) + + val manager = ConcordSessionManager(communities, owner.pubKey, backgroundScope) + testScheduler.runCurrent() + + // The joined community produced a session, and its control plane is in the subscribe set. + assertTrue(manager.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex)) + val revAfterSync = manager.revision.value + assertTrue(revAfterSync > 0) + + // Ingesting the genesis control wraps folds Alpha and advances the revision. + alpha.genesisWraps.forEach { assertTrue(manager.ingest(it)) } + testScheduler.runCurrent() + assertEquals( + "Alpha", + manager + .sessionFor(alpha.communityIdHex) + ?.state + ?.value + ?.metadata + ?.name, + ) + assertTrue(manager.revision.value > revAfterSync) + + // The folded #general channel plane is now part of the subscribe set. + val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) + assertTrue(manager.subscribeAddresses().contains(general.publicKeyHex)) + + // Joining a second community via the flow creates its session too. + val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example")) + communities.value = listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta")) + testScheduler.runCurrent() + assertTrue(manager.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) + // Alpha's fold survived the re-sync. + assertEquals( + "Alpha", + manager + .sessionFor(alpha.communityIdHex) + ?.state + ?.value + ?.metadata + ?.name, + ) + } + + @Test + fun exposesStreamKeysScopedToTheCommunityRelaysForNip42Auth() = + runTest { + val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val communities = MutableStateFlow(listOf(entryFor(alpha, "Alpha"))) + + val manager = ConcordSessionManager(communities, owner.pubKey, backgroundScope) + testScheduler.runCurrent() + + val hosted = RelayUrlNormalizer.normalize("wss://r.example") + val elsewhere = RelayUrlNormalizer.normalize("wss://other.example") + + // Before any fold, only the control-plane key must AUTH — and only on the community's relay. + val beforeFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() } + assertTrue(beforeFold.contains(alpha.controlPlane.secretKey.toHexKey())) + assertTrue(manager.streamAuthSecretsFor(elsewhere).isEmpty()) // relay-scoped + + // After the Control Plane folds, the #general channel key joins the AUTH set. + alpha.genesisWraps.forEach { manager.ingest(it) } + testScheduler.runCurrent() + val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) + val afterFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() } + assertTrue(afterFold.contains(alpha.controlPlane.secretKey.toHexKey())) + assertTrue(afterFold.contains(general.secretKey.toHexKey())) + assertFalse(manager.streamAuthSecretsFor(elsewhere).any { it.toHexKey() == general.secretKey.toHexKey() }) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt new file mode 100644 index 0000000000..b5df7c332a --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordSessionRegistryTest { + private val owner = NostrSignerInternal(KeyPair()) + + private fun entryFor( + community: NewConcordCommunity, + name: String, + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = name, + ) + + @Test + fun syncsSessionsRoutesWrapsAndDropsDepartedCommunities() = + runTest { + val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example")) + + val captured = mutableListOf>() + val registry = ConcordSessionRegistry { communityId, channelIdHex, rumor -> captured += Triple(communityId, channelIdHex, rumor) } + + // First sync creates a session for each joined community. + val created = registry.sync(listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta")), owner.pubKey) + assertEquals(setOf(alpha.communityIdHex, beta.communityIdHex), created) + assertNotNull(registry.sessionFor(alpha.communityIdHex)) + assertNotNull(registry.sessionFor(beta.communityIdHex)) + + // Both control-plane addresses are in the subscribe set from the entries alone. + assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex)) + assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) + + // A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL). + alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, registry.ingest(it)) } + val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value + assertEquals("Alpha", alphaState?.metadata?.name) + + // After the fold, Alpha's #general channel plane joins the subscribe set. + val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) + assertTrue(registry.subscribeAddresses().contains(general.publicKeyHex)) + + // A channel message decrypts and is emitted to the sink for Alpha's #general. + val msg = ConcordActions.buildChannelMessage(owner, general, alpha.generalChannelIdHex, alpha.rootEpoch, "gm", 2L) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, registry.ingest(msg)) + val general9 = captured.filter { it.first == alpha.communityIdHex && it.second == alpha.generalChannelIdHex && it.third.content == "gm" } + assertEquals(1, general9.size) + + // A re-sync that keeps Alpha but drops Beta preserves Alpha's folded state and removes Beta. + val createdAgain = registry.sync(listOf(entryFor(alpha, "Alpha")), owner.pubKey) + assertTrue(createdAgain.isEmpty()) + assertNotNull(registry.sessionFor(alpha.communityIdHex)) + assertNull(registry.sessionFor(beta.communityIdHex)) + assertEquals( + "Alpha", + registry + .sessionFor(alpha.communityIdHex)!! + .state.value + ?.metadata + ?.name, + ) + + // A wrap from an unknown community is routed nowhere. + val gamma = ConcordCommunityFactory.create(owner, "Gamma", createdAt = 1L, relays = listOf("wss://r.example")) + assertEquals(ConcordIngestOutcome.NOT_MINE, registry.ingest(gamma.genesisWraps.first())) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt index 0f6de969a9..fa06c42d16 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt @@ -105,7 +105,7 @@ class DesktopAuthCoordinator( RelayAuthenticator( client = relayManager.client, scope = scope, - signWithAllLoggedInUsers = { relayUrl, template -> + signWithAllLoggedInUsers = { relayUrl, template, _ -> val signed = signWithPolicy(account, relayUrl, template, policy) signed?.let { listOf(it) } ?: emptyList() }, diff --git a/quartz/plans/2026-07-11-concord-event-classes.md b/quartz/plans/2026-07-11-concord-event-classes.md new file mode 100644 index 0000000000..82e57ac9a4 --- /dev/null +++ b/quartz/plans/2026-07-11-concord-event-classes.md @@ -0,0 +1,88 @@ +# Concord event layer → per-kind Event classes (nip88Polls structure) + +## Why + +The Concord quartz layer currently centralizes wire kinds in a single +`concord/events/ConcordKinds.kt` constant object and hand-rolls rumors with raw +string tags (see `cord03Channels/ChannelChat.kt`: +`RumorAssembler.assembleRumor(kind = ConcordKinds.MESSAGE, tags = arrayOf(arrayOf("q", …)))`). +Two problems: + +1. **Duplicates standard Nostr kinds.** `ConcordKinds.MESSAGE=9`, `REACTION=7`, + `DELETE=5`, `COMMENT=1111`, `EDIT=3302` shadow `ChatEvent.KIND`, + `ReactionEvent.KIND`, `DeletionEvent.KIND`, `CommentEvent.KIND`. Concord chat + rumors *are* those standard events (they already parse back as `ChatEvent` + etc. on read) — the build side should reuse the classes, not re-derive by + number. +2. **No per-event structure.** Every other protocol in quartz gives each event + kind a package: `XxxEvent.kt` (the `Event` subclass + `build`), plus + `TagArrayBuilderExt.kt` / `TagArrayExt.kt` and a `tags/` folder of typed tag + classes (`nip88Polls/poll/…` is the reference). Concord instead has loose + builders (`ChannelChat`, `ControlEditionBuilder`) and stringly-typed tags. + +Target: match the `nip88Polls` shape. Reuse standard events where the protocol +uses standard kinds; give each genuinely-Concord kind its own package. + +## Reuse standard events (chat plane, CORD-03) + +A Concord chat rumor is a standard event + a `["channel", id]` + `["epoch", n]` +binding. Introduce a binding-tag package and reuse the standard builders: + +- `cord03Channels/tags/ChannelTag.kt`, `tags/EpochTag.kt` — typed tags. +- `cord03Channels/TagArrayBuilderExt.kt` — `channel(id)`, `epoch(n)` on the DSL. +- `cord03Channels/TagArrayExt.kt` — `channelId()`, `epoch()`, `isBoundTo(...)`. + +| rumor | reuse | binding | +|---|---|---| +| message (9) | `ChatEvent.build` | `channel` + `epoch` | +| reply (9 + q) | `ChatEvent.build` + `q`/`p` | `channel` + `epoch` | +| reaction (7) | `ReactionEvent.build` | `channel` + `epoch` | +| delete (5) | `DeletionEvent.build` | `channel` + `epoch` | + +`ChannelChat` keeps its public API (returns unsigned rumors via `RumorAssembler`) +but builds tags from the standard event's DSL + the binding ext. Delete +`ConcordKinds.MESSAGE/REACTION/DELETE/COMMENT/EDIT`. + +**Interop guard:** the exact on-wire tags must stay byte-identical to today's +output (Armada compat). Keep `["q", parentId]` + `["p", parentAuthor]` for +replies and `["e"/"p"/"k"]` for reactions — do not switch to `QEventTag`'s +3-element form. Verify with `ConcordPlaneRegistryTest` + an amy↔Armada round-trip. + +## New per-kind Event classes (genuinely Concord) + +Each gets `concord///XxxEvent.kt` + `TagArrayBuilderExt` + +`TagArrayExt` + `tags/`: + +| kind | event | CORD | +|---|---|---| +| 3308 | `ControlEditionEvent` (from `ControlEdition`/`ControlEditionBuilder`) | 02/04/06 | +| 3303 | `RekeyEvent` | 06 | +| 3306 / 3309 / 3312 | `GuestbookJoinLeaveEvent` / `KickEvent` / `SnapshotEvent` | 02 | +| 3313 | `DirectInviteEvent` | 05 | +| 23311 / 23313 | `TypingEvent` / `VoicePresenceEvent` | 03/07 | +| 3310 | `WebxdcEvent` | 03 | +| 33301 | `InviteBundleEvent` (from `ConcordInviteBundle`) | 05 | +| 13303 | `InviteListEvent` | 05 | +| 20013 / 20014 / 1059 / 21059 | envelope seals + inverted wrap | 01 | + +`ConcordCommunityListEvent` (13302) is already an `Event` class (now a +`BaseReplaceableEvent`) — keep, just move under a per-kind package if desired. + +After the move, `ConcordKinds` retains only the truly-Concord kinds (envelope, +control, rekey, guestbook, invites, voice), not the standard-Nostr aliases. + +## Sequencing (incremental, compile + interop-test each) + +1. ✅ Chat plane reuse — `ChatEvent`/`ReactionEvent` + `cord03Channels/tags` + + ext; dropped `MESSAGE/REACTION/DELETE/COMMENT` aliases. (commit c2cbd602) +2. ✅ Control plane 3308 → `cord04Roles/control/ControlEditionEvent` package + + `tags/` (vsk/eid/ev/ep/vac) + ext; `EventFactory` registers 3308. (commit dc2abc0e) +3. Invites: ✅ 33301 bundle → `cord05Invites/bundle/ConcordInviteBundleEvent` + (addressable, reuses `DTag` + `VskTag`; registered in `EventFactory`). Remaining: + 3313 direct-invite → `ConcordDirectInviteEvent` (the rumor has empty tags; the + `p`/`k` index lives on the giftwrap). 13303 invite-list has no implementation yet + (a bare `ConcordKinds` constant) — build it when the private-invite feature lands. +4. Guestbook (3306/3309/3312) + voice (23313/23311) + rekey (3303). **← next** +5. Envelope seals (20013/20014) / wrap (1059/21059). +6. Shrink `ConcordKinds` to only kinds without their own event class; audit + `EventFactory` coverage. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt new file mode 100644 index 0000000000..f7d320c5ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A freshly created Concord community: its self-certifying identity and access + * secrets, plus the genesis Control Plane wraps to publish and the equivalent + * editions to fold locally. + */ +class NewConcordCommunity( + val communityId: ByteArray, + val ownerPubKey: String, + val ownerSalt: ByteArray, + val communityRoot: ByteArray, + val rootEpoch: Long, + val generalChannelId: ByteArray, + val controlPlane: GroupKey, + /** The kind-1059 control-plane wraps to publish (metadata + #general). */ + val genesisWraps: List, + /** The same editions as parsed [ControlEdition]s, for immediate local folding. */ + val genesisEditions: List, +) { + val communityIdHex: String get() = communityId.toHexKey() + val generalChannelIdHex: String get() = generalChannelId.toHexKey() +} + +/** + * Creates new Concord communities (CORD-02 Genesis). + * + * `create` mints a random `owner_salt`, derives the self-certifying + * `community_id = sha256("concord/community" ‖ owner ‖ salt)`, generates an + * independent random `community_root` (so access can rotate while identity stays + * fixed), and emits exactly two owner-signed genesis editions — the community + * metadata and a public `#general` channel — as plaintext-seal wraps on the + * Control Plane at epoch 0. + */ +object ConcordCommunityFactory { + const val GENERAL_CHANNEL_NAME = "general" + + suspend fun create( + ownerSigner: NostrSigner, + name: String, + createdAt: Long, + description: String? = null, + relays: List = emptyList(), + icon: ImagePointer? = null, + ): NewConcordCommunity { + val ownerXOnly = ownerSigner.pubKey.hexToByteArray() + val ownerSalt = ConcordKeyDerivation.newOwnerSalt() + val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt) + val communityRoot = RandomInstance.bytes(32) + val generalChannelId = RandomInstance.bytes(32) + val rootEpoch = 0L + val controlPlane = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch) + + val metadataJson = + ConcordJson.instance.encodeToString( + MetadataEntity.serializer(), + MetadataEntity(name = name, icon = icon, description = description, relays = relays), + ) + val channelJson = + ConcordJson.instance.encodeToString( + ChannelEntity.serializer(), + ChannelEntity(name = GENERAL_CHANNEL_NAME, private = false), + ) + + val metadataRumor = + ControlEditionBuilder.rumor( + authorPubKey = ownerSigner.pubKey, + entityKind = ControlEntityKind.METADATA, + entityId = communityId, // metadata eid == community id + version = 0, + prevHash = null, + content = metadataJson, + createdAt = createdAt, + ) + val channelRumor = + ControlEditionBuilder.rumor( + authorPubKey = ownerSigner.pubKey, + entityKind = ControlEntityKind.CHANNEL, + entityId = generalChannelId, // channel eid == channel id + version = 0, + prevHash = null, + content = channelJson, + createdAt = createdAt, + ) + + // Control Plane uses plaintext (20014) seals so signatures survive re-encryption across epochs. + val metadataWrap = ConcordStreamEnvelope.wrap(metadataRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt) + val channelWrap = ConcordStreamEnvelope.wrap(channelRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt) + + return NewConcordCommunity( + communityId = communityId, + ownerPubKey = ownerSigner.pubKey, + ownerSalt = ownerSalt, + communityRoot = communityRoot, + rootEpoch = rootEpoch, + generalChannelId = generalChannelId, + controlPlane = controlPlane, + genesisWraps = listOf(metadataWrap, channelWrap), + genesisEditions = + listOfNotNull( + ControlEdition.fromRumor(metadataRumor), + ControlEdition.fromRumor(channelRumor), + ), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt new file mode 100644 index 0000000000..c59e604dc5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** A past root key for a specific epoch, kept so historical channel keys stay derivable. */ +@Serializable +class HeldRoot( + val epoch: Long, + val key: String, +) + +/** A private channel's delivered key at a given epoch (for private channels the member can read). */ +@Serializable +class PrivateChannelKey( + val channelId: String, + val key: String, + val epoch: Long, + val name: String = "", +) + +/** + * One joined Concord community in the member's private list. Carries everything + * needed to re-derive the community's planes on any device: identity ([id], + * [owner], [ownerSalt]), the current access [root] at [rootEpoch] plus past + * [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached + * display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks + * liveness against tombstones. + */ +@Serializable +class ConcordCommunityListEntry( + val id: String, + val owner: String, + val ownerSalt: String, + val root: String, + val rootEpoch: Long = 0, + val heldRoots: List = emptyList(), + val privateChannels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", + val addedAt: Long = 0, +) + +/** + * The member's private, self-encrypted list of joined Concord communities + * (kind [ConcordCommunityListEvent.KIND] = 13302, CORD-05) — the NIP-51 analog that + * lets a client return to the groups the user signed up for. Replaceable and + * NIP-44-encrypted to the member's own key, so relays store only ciphertext. + * + * The plaintext document is wire-compatible with Soapbox Armada's `communityList.ts`: + * `{ "entries": [ { "community_id", "seed": JoinMaterial, "current": JoinMaterial, + * "added_at" } ], "tombstones": [ { "community_id", "removed_at" } ] }`, where + * [JoinMaterialWire] is the snake_case per-snapshot key bundle. Liveness is derived — + * an entry is dropped only when a later tombstone removes it — and each entry keeps a + * [CommunityListEntryWire.seed] (backfill anchor) plus [CommunityListEntryWire.current] + * (latest) snapshot; we hydrate from `current`, falling back to `seed`. + * + * (Channels are not listed here beyond their private keys: once the [root] is held, + * folding the Control Plane yields the community's channels.) + */ +object ConcordCommunityList { + // ---- wire DTOs (snake_case, Armada communityList.ts) ---------------------- + + @Serializable + private class WireChannel( + val id: String, + val key: String, + val epoch: Long, + val name: String = "", + ) + + @Serializable + private class WireHeldRoot( + val epoch: Long, + val key: String, + ) + + @Serializable + private class JoinMaterialWire( + @SerialName("community_id") val communityId: String, + val owner: String, + @SerialName("owner_salt") val ownerSalt: String, + @SerialName("community_root") val communityRoot: String, + @SerialName("root_epoch") val rootEpoch: Long, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", + @SerialName("held_roots") val heldRoots: List = emptyList(), + val refounder: String? = null, + ) + + @Serializable + private class CommunityListEntryWire( + @SerialName("community_id") val communityId: String, + val seed: JoinMaterialWire? = null, + val current: JoinMaterialWire? = null, + @SerialName("added_at") val addedAt: Long = 0, + ) + + @Serializable + private class CommunityTombstoneWire( + @SerialName("community_id") val communityId: String, + @SerialName("removed_at") val removedAt: Long = 0, + ) + + @Serializable + private class CommunityListDoc( + val entries: List = emptyList(), + val tombstones: List = emptyList(), + ) + + private fun ConcordCommunityListEntry.toJoinMaterial() = + JoinMaterialWire( + communityId = id, + owner = owner, + ownerSalt = ownerSalt, + communityRoot = root, + rootEpoch = rootEpoch, + channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name) }, + relays = relays, + name = name, + heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) }, + ) + + private fun JoinMaterialWire.toEntry(addedAt: Long) = + ConcordCommunityListEntry( + id = communityId, + owner = owner, + ownerSalt = ownerSalt, + root = communityRoot, + rootEpoch = rootEpoch, + heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) }, + privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) }, + relays = relays, + name = name, + addedAt = addedAt, + ) + + // ---- build / codec -------------------------------------------------------- + + /** Builds the encrypted kind-13302 list event from [entries], signed by [signer]. */ + suspend fun build( + signer: NostrSigner, + entries: List, + createdAt: Long, + ): Event { + val content = signer.nip44Encrypt(encode(entries), signer.pubKey) + return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content) + } + + /** Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted. */ + fun encode(entries: List): String { + val doc = + CommunityListDoc( + entries = + entries.map { e -> + val jm = e.toJoinMaterial() + CommunityListEntryWire( + communityId = e.id, + seed = jm, + current = jm, + addedAt = e.addedAt, + ) + }, + tombstones = emptyList(), + ) + return ConcordJson.instance.encodeToString(CommunityListDoc.serializer(), doc) + } + + /** + * Parses the decrypted plaintext JSON document back into live entries, or empty on + * failure. An entry is live unless a tombstone for the same community removed it + * strictly after it was added; hydration prefers `current`, falling back to `seed`. + */ + fun decode(json: String): List = + try { + val doc = ConcordJson.instance.decodeFromString(CommunityListDoc.serializer(), json) + val latestRemoval = HashMap() + for (t in doc.tombstones) { + val prev = latestRemoval[t.communityId] + if (prev == null || t.removedAt > prev) latestRemoval[t.communityId] = t.removedAt + } + doc.entries.mapNotNull { e -> + val removedAt = latestRemoval[e.communityId] + if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null + (e.current ?: e.seed)?.toEntry(e.addedAt) + } + } catch (_: Exception) { + emptyList() + } + + /** Decrypts and parses a kind-13302 list event with [signer], or empty on failure. */ + suspend fun parse( + event: Event, + signer: NostrSigner, + ): List { + if (event.kind != ConcordCommunityListEvent.KIND) return emptyList() + return try { + decode(signer.nip44Decrypt(event.content, signer.pubKey)) + } catch (_: Exception) { + emptyList() + } + } + + /** + * Merges two decrypted lists (e.g. from two devices), keeping one entry per + * community id. When both hold the same community, the one with the higher + * [ConcordCommunityListEntry.rootEpoch] wins so the freshest access key + * survives. + */ + fun merge( + a: List, + b: List, + ): List { + val byId = LinkedHashMap() + for (e in a + b) { + val existing = byId[e.id] + if (existing == null || e.rootEpoch > existing.rootEpoch) byId[e.id] = e + } + return byId.values.toList() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt new file mode 100644 index 0000000000..934841648a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The member's private, self-encrypted list of joined Concord communities (kind + * 13302, CORD-05). A replaceable event whose + * `content` is the NIP-44 self-encryption of the [ConcordCommunityListEntry] JSON + * — including each community's secrets (`community_root`, salt, epoch, + * private-channel keys), so a single event both syncs membership across devices + * and carries the keys needed to re-derive every plane. + * + * This is the Concord analog of NIP-29's kind-10009 `SimpleGroupListEvent` and + * NIP-17's chatroom home base; it is what the Account's `ConcordChannelListState` + * observes. Only the owner's key decrypts it, so relays store ciphertext. + */ +@Immutable +class ConcordCommunityListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** Decrypts this list's entries with [signer], or empty on failure / wrong key. */ + suspend fun decrypt(signer: NostrSigner): List = + try { + ConcordCommunityList.decode(signer.nip44Decrypt(content, signer.pubKey)) + } catch (_: Exception) { + emptyList() + } + + companion object { + const val KIND = 13302 + const val ALT = "Private list of joined Concord communities" + + /** The replaceable coordinate for a member's list: `(13302, pubkey, "")`. */ + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "") + + /** Builds a signed, self-encrypted list event from [entries]. */ + suspend fun create( + signer: NostrSigner, + entries: List, + createdAt: Long = TimeUtils.now(), + ): ConcordCommunityListEvent { + val content = signer.nip44Encrypt(ConcordCommunityList.encode(entries), signer.pubKey) + return signer.sign(createdAt, KIND, emptyArray(), content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt new file mode 100644 index 0000000000..83a44f55ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity + +/** A channel id paired with its current folded definition. */ +class ConcordChannel( + val channelIdHex: String, + val definition: ChannelEntity, +) + +/** + * The current, folded state of a Concord community's Control Plane (CORD-02). + * + * Produced by [fold] from the set of decrypted, verified control editions plus + * the community's known [ownerPubKey]. It exposes the community [metadata], the + * live (non-deleted) [channels], the live [roles], the owner-rooted [authority] + * resolver, and whether the community has been [dissolved]. + * + * "Every member keeps the entire Control Plane in sync — it is small and must + * stay complete." Recompute this whenever the known editions change. + */ +class ConcordCommunityState( + val ownerPubKey: String, + val metadata: MetadataEntity?, + val channels: Map, + val roles: Map, + val authority: AuthorityResolver, + val dissolved: Boolean, +) { + companion object { + fun fold( + editions: Collection, + ownerPubKey: String, + ): ConcordCommunityState { + val heads = EditionFold.fold(editions).values + // Resolve authority from the FULL edition set (not the structural heads): the resolver + // folds each role/grant chain through authorized editions only, so a rogue higher-version + // edition can't supersede a legit one before authority is even judged. + val authority = AuthorityResolver.resolve(editions, ownerPubKey) + + // CORD-04 §1: "an edition whose signer isn't authorized is dropped." Authority is + // owner-rooted (the AuthorityResolver resolves it from the owner outward via the grant + // fixpoint), so gating each managed entity by its required permission BEFORE the + // structural fold filters out spoofed editions — e.g. a decoy metadata genesis minted by + // an unprivileged key — instead of letting a higher-version forgery win the chain. The + // permission check also excludes banned authors (hasPermission is false for a banned npub). + fun editorsWith( + kind: ControlEntityKind, + bit: Int, + ): List = + editions.filter { + it.entityKind == kind && (authority.isOwner(it.author) || authority.hasPermission(it.author, bit)) + } + + // Metadata is one entity (== community id), gated by MANAGE_METADATA. Fold only the + // authorized editions, then take the highest-version head (guarding against strays). + val metadata = + EditionFold + .fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)) + .values + .maxByOrNull { it.version } + ?.let { ConcordJson.decodeOrNull(it.content) } + + // Channels are gated by MANAGE_CHANNELS. Fold each channel entity from its authorized + // editions only, dropping the tombstoned ones. + val channels = LinkedHashMap() + for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS)).values) { + val def = ConcordJson.decodeOrNull(head.content) ?: continue + if (def.deleted) continue + channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def) + } + + // Role definitions come from the authority-gated fold (not the raw structural heads): a + // rogue can mint a higher-version edition on a legit role's coordinate (e.g. marking the + // Admin role deleted) that would win a structural fold and corrupt the displayed roster, + // so we take the roles the AuthorityResolver actually accepted from the owner outward. + val roles = authority.roles() + + // Dissolution is owner-only — a rogue tombstone must not appear to kill the community. + val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED && authority.isOwner(it.author) } + + return ConcordCommunityState( + ownerPubKey = ownerPubKey.lowercase(), + metadata = metadata, + channels = channels, + roles = roles, + authority = authority, + dissolved = dissolved, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt new file mode 100644 index 0000000000..d7566f394f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** A self-signed membership motion on the Guestbook Plane. */ +enum class GuestbookAction( + val wire: String, +) { + JOIN("join"), + LEAVE("leave"), + ; + + companion object { + fun of(wire: String) = entries.firstOrNull { it.wire == wire } + } +} + +/** A parsed Guestbook join/leave: who ([member]), what ([action]), and invite attribution. */ +class GuestbookEntry( + val member: HexKey, + val action: GuestbookAction, + val createdAt: Long, + val inviteCreator: HexKey?, + val inviteLabel: String?, +) + +/** + * The Guestbook Plane (CORD-02): self-signed Joins and Leaves plus authorized + * Kicks that track membership motion. It is **off-consensus** — nothing in the + * Control or Chat planes depends on it — so it is best-effort presence, not + * authority. + * + * Rumor builders here are unsigned; seal + wrap them onto the community's + * Guestbook plane with + * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope]. + */ +object Guestbook { + /** Guestbook rumor kinds (CORD-02): self-signed join/leave and authorized kick. */ + const val KIND_JOIN_LEAVE = 3306 + const val KIND_KICK = 3309 + + const val TAG_MS = "ms" + const val TAG_INVITE = "invite" + const val TAG_P = "p" + + /** A self-signed join (kind 3306), optionally attributing the invite used. */ + fun join( + memberPubKey: HexKey, + createdAt: Long, + subMs: Int? = null, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ): Event = motion(memberPubKey, GuestbookAction.JOIN, createdAt, subMs, inviteCreator, inviteLabel) + + /** A self-signed leave (kind 3306). */ + fun leave( + memberPubKey: HexKey, + createdAt: Long, + subMs: Int? = null, + ): Event = motion(memberPubKey, GuestbookAction.LEAVE, createdAt, subMs, null, null) + + private fun motion( + memberPubKey: HexKey, + action: GuestbookAction, + createdAt: Long, + subMs: Int?, + inviteCreator: HexKey?, + inviteLabel: String?, + ): Event { + val tags = ArrayList>(2) + if (subMs != null) tags.add(arrayOf(TAG_MS, subMs.toString())) + if (inviteCreator != null) { + tags.add(if (inviteLabel != null) arrayOf(TAG_INVITE, inviteCreator, inviteLabel) else arrayOf(TAG_INVITE, inviteCreator)) + } + return RumorAssembler.assembleRumor(memberPubKey, createdAt, KIND_JOIN_LEAVE, tags.toTypedArray(), action.wire) + } + + /** + * An authorized Kick (kind 3309) directing [target] to leave. A Kick is only + * honored by clients when its signer holds [com.vitorpamplona.quartz.concord + * .cord04Roles.ConcordPermissions.KICK] and outranks the target — a Kick from + * a non-KICK holder is dropped (CORD-04 §The Three Removals). + */ + fun kick( + actorPubKey: HexKey, + target: HexKey, + createdAt: Long, + ): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, KIND_KICK, arrayOf(arrayOf(TAG_P, target)), "") + + /** Parses a Guestbook join/leave rumor, or null if it is not one. */ + fun parse(rumor: Event): GuestbookEntry? { + if (rumor.kind != KIND_JOIN_LEAVE) return null + val action = GuestbookAction.of(rumor.content) ?: return null + val invite = rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE } + return GuestbookEntry( + member = rumor.pubKey, + action = action, + createdAt = rumor.createdAt, + inviteCreator = invite?.getOrNull(1), + inviteLabel = invite?.getOrNull(2), + ) + } + + /** The kick target's pubkey from a kind-3309 rumor, or null. */ + fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == KIND_KICK) rumor.tags.firstTagValue(TAG_P) else null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt new file mode 100644 index 0000000000..8c194f660e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.Serializable + +/** + * A CORD-02 §6 **encrypted-media** pointer (community/channel icon or banner). The media host stores + * only ciphertext; the per-image AES-256-GCM [key] + [nonce] ride inside the member-sealed Control + * Plane metadata, and [hash] is the SHA-256 of the *plaintext* so a swapped blob fails closed. + * + * Wire shape is pinned to the Concord v2 reference client (`concord-v2/lib/types.ts`): an object, + * not a URL string — a member fetches [url], AES-256-GCM-decrypts with [key]/[nonce], then verifies + * the plaintext SHA-256 equals [hash] before displaying. + */ +@Serializable +data class ImagePointer( + val url: String = "", + /** Hex AES-256-GCM key (32 bytes). */ + val key: String = "", + /** Hex AES-GCM nonce / IV (16 bytes). */ + val nonce: String = "", + /** Hex SHA-256 of the plaintext, for integrity. */ + val hash: String = "", +) { + /** True once every field needed to fetch + decrypt is present. */ + fun isResolvable(): Boolean = url.isNotBlank() && key.isNotBlank() && nonce.isNotBlank() && hash.isNotBlank() + + /** + * Decrypt the fetched [ciphertext] blob (AES-256-GCM under [key]/[nonce], CORD-02 §6) and verify + * the plaintext SHA-256 against [hash]. Returns the plaintext image bytes, or null if decryption or + * the integrity check fails — a swapped or corrupt blob fails closed rather than rendering garbage. + */ + fun decryptOrNull(ciphertext: ByteArray): ByteArray? { + val plaintext = AESGCM(key.hexToByteArray(), nonce.hexToByteArray()).decryptOrNull(ciphertext) ?: return null + if (sha256(plaintext).toHexKey() != hash.lowercase()) return null + return plaintext + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt new file mode 100644 index 0000000000..9c4f79135e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.ciphers.AESGCM + +/** + * Chat Plane message binding (CORD-03). + * + * A Concord chat rumor **is** a standard Nostr event — a kind-9 [ChatEvent] + * message/reply or a kind-7 [ReactionEvent] — that additionally commits to the + * channel and epoch it belongs to via `["channel", ]` + `["epoch", ]` tags + * (see [channel]/[epoch] and [ChannelTag]/[EpochTag]). This object reuses the + * standard event builders and only adds the binding, so the same event classes + * that render everywhere else in the app render Concord messages too. Recipients + * enforce the binding ([TagArray.isConcordBoundTo]) so an event lifted from one + * channel/epoch can't be replayed into another. + */ +object ChannelChat { + /** + * Builds an unsigned kind-9 [ChatEvent] rumor bound to [channelId]/[epoch]. + * Wrap it for the channel plane with + * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope] to publish. + */ + fun message( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + authorPubKey, + ChatEvent.build(text, createdAt) { + channelBinding(channelId, epoch) + extraTags.forEach { addUnique(it) } + }, + ) + + /** + * Builds an unsigned kind-9 **inline quote-reply** to [parentId]: a normal + * channel [message] that quotes the parent via a `q` tag (NIP-C7) and credits + * its author with a `p` tag. Unlike [reply] (a kind-1111 thread comment pulled + * into a minichat), an inline quote stays in the main chat timeline — the two + * reply modes the composer offers. Matches Armada, where a kind-9 `q` is an + * inline quote deliberately kept out of threads. + */ + fun inlineReply( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + parentId: HexKey, + parentAuthor: HexKey, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + message( + authorPubKey = authorPubKey, + channelId = channelId, + epoch = epoch, + text = text, + createdAt = createdAt, + extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags, + ) + + /** + * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to + * [parent], bound to [channelId]/[epoch]. + * + * A thread reply is a NIP-22 comment — NOT a kind-9 message with a `q` tag + * (which NIP-C7 reserves for *inline quotes* that clients deliberately keep out + * of threads). [CommentEvent.replyBuilder] emits the uppercase `K`/`E`/`P` + * pointers at the immutable thread root and the lowercase `k`/`e`/`p` pointers + * at the immediate [parent] (inheriting the root when [parent] is itself a + * comment, so the root is stable at any depth). We add the same + * `["channel", …]` + `["epoch", …]` binding every Chat Plane rumor carries, so + * the reply is verifiable against the plane it arrives on. This is exactly the + * shape Soapbox Armada builds and groups into a message's thread. + */ + fun reply( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + parent: Event, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + authorPubKey, + CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) { + channelBinding(channelId, epoch) + extraTags.forEach { add(it) } + }, + ) + + /** + * Builds an unsigned kind-7 [ReactionEvent] rumor bound to [channelId]/[epoch] + * against the target message ([targetId]/[targetAuthor]/[targetKind]). [content] + * is the reaction (e.g. `"+"`, `"🤙"`). Kept to the minimal `e`/`p`/`k` tag form + * (no relay hints) so it stays wire-identical across clients. On the receiving + * side it decrypts to a normal kind-7 that wires to its target Note by the `e` + * tag through the shared cache. + */ + fun reaction( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + targetId: HexKey, + targetAuthor: HexKey, + targetKind: Int, + content: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = ReactionEvent.KIND, + tags = + arrayOf( + ChannelTag.assemble(channelId), + EpochTag.assemble(epoch), + arrayOf("e", targetId), + arrayOf("p", targetAuthor), + arrayOf("k", targetKind.toString()), + ) + extraTags, + content = content, + ) + + /** + * Builds an unsigned kind-9 message carrying one or more **encrypted image** attachments + * ([imetas]), wire-identical to Soapbox Armada's `encryptAttachments` path so images interop + * across Concord clients. Each attachment's ciphertext URL is appended to the text content (the + * ones not already present), exactly as Armada assembles it, and each rides as a NIP-92 `imeta` + * tag ([encryptedImageImeta]). The message is still a normal channel-bound kind-9, so the shared + * feed renders it and the binding is enforced like any other Chat Plane rumor. + */ + fun imageMessage( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + imetas: List, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } + val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") + return message( + authorPubKey = authorPubKey, + channelId = channelId, + epoch = epoch, + text = finalText, + createdAt = createdAt, + extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, + ) + } + + /** + * Builds the encrypted-image `imeta` tag Armada's `ChatComposer` emits with `encryptAttachments`: + * `url` (ciphertext blob), `m` (plaintext mime), `dim`, `blurhash`, plus `encryption-algorithm` + * (`aes-gcm`), `decryption-key`, `decryption-nonce` (hex), and `ox` (the *plaintext* SHA-256 for + * integrity). Deliberately omits `x` (a ciphertext hash) to match Armada exactly. + */ + fun encryptedImageImeta( + url: String, + mimeType: String?, + dim: String?, + blurhash: String?, + cipher: AESGCM, + originalHash: String?, + ): IMetaTag = + IMetaTagBuilder(url) + .apply { + mimeType?.let { add("m", it) } + dim?.let { add("dim", it) } + blurhash?.let { add("blurhash", it) } + add(EncryptionAlgo.TAG_NAME, cipher.name()) + add(EncryptionKey.TAG_NAME, cipher.keyBytes.toHexKey()) + add(EncryptionNonce.TAG_NAME, cipher.nonce.toHexKey()) + originalHash?.let { add(OriginalHashTag.TAG_NAME, it) } + }.build() + + /** + * Parses every **encrypted image** attachment ([ConcordImageAttachment]) carried on [rumor] as an + * `imeta` tag with the `aes-gcm` `decryption-key`/`decryption-nonce` fields. A plaintext imeta + * (no encryption fields) is ignored here — it renders through the normal media path. + */ + fun encryptedImagesOf(rumor: Event): List = + rumor.tags + .mapNotNull { if (it.size >= 2 && it[0] == IMetaTag.TAG_NAME) IMetaTag.parse(it) else null } + .flatten() + .mapNotNull { it.toEncryptedAttachmentOrNull() } + + private fun IMetaTag.prop(key: String): String? = properties[key]?.firstOrNull()?.takeIf { it.isNotEmpty() } + + private fun IMetaTag.toEncryptedAttachmentOrNull(): ConcordImageAttachment? { + val key = prop(EncryptionKey.TAG_NAME) ?: return null + val nonce = prop(EncryptionNonce.TAG_NAME) ?: return null + val algo = prop(EncryptionAlgo.TAG_NAME) ?: return null + val keyBytes = runCatching { key.hexToByteArray() }.getOrNull() ?: return null + val nonceBytes = runCatching { nonce.hexToByteArray() }.getOrNull() ?: return null + return ConcordImageAttachment( + url = url, + mimeType = prop("m"), + dim = prop("dim"), + blurhash = prop("blurhash"), + algo = algo, + key = keyBytes, + nonce = nonceBytes, + originalHash = prop(OriginalHashTag.TAG_NAME), + ) + } + + /** Chat Plane typing indicator (CORD-03): a transient "user is composing" heartbeat. */ + const val KIND_TYPING = 23311 + + /** + * Builds an unsigned kind-23311 typing heartbeat bound to [channelId]/[epoch]. + * Empty content; wrap it as an **ephemeral** stream event (kind 21059) so relays + * broadcast but never store it. Republish every few seconds while composing; a + * receiver shows the author as typing until the heartbeat goes stale. + */ + fun typing( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + createdAt: Long, + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = KIND_TYPING, + tags = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), + content = "", + ) + + /** True when [rumor] is a typing heartbeat (kind 23311). */ + fun isTyping(rumor: Event): Boolean = rumor.kind == KIND_TYPING + + /** The channel id a Chat Plane [rumor] is bound to, or null if unbound. */ + fun channelOf(rumor: Event): HexKey? = rumor.tags.concordChannel() + + /** The epoch a Chat Plane [rumor] is bound to, or null if unbound/malformed. */ + fun epochOf(rumor: Event): Long? = rumor.tags.concordEpoch() + + /** + * True when [rumor] is bound to exactly [channelId] and [epoch]. Recipients must + * reject any Chat Plane event whose binding does not match the plane it arrived on. + */ + fun isBoundTo( + rumor: Event, + channelId: HexKey, + epoch: Long, + ): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch) +} + +/** + * A decrypted-pointer to an **encrypted image** attached to a Concord chat message (CORD-03), parsed + * from a NIP-92 `imeta` tag ([ChannelChat.encryptedImagesOf]). The [url] blob is AES-256-GCM + * ciphertext on a media host; fetch it, decrypt with [key]/[nonce], and verify the plaintext SHA-256 + * equals [originalHash] before displaying. Mirrors Soapbox Armada's encrypted attachment for interop. + */ +class ConcordImageAttachment( + val url: String, + val mimeType: String?, + val dim: String?, + val blurhash: String?, + val algo: String, + val key: ByteArray, + val nonce: ByteArray, + val originalHash: String?, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelId.kt new file mode 100644 index 0000000000..07724c4a1b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelId.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The stable identity of a Concord channel: the community it belongs to plus the + * channel's own id. Mirrors NIP-29's `GroupId` (host relay + group id) — the + * universal address a UI keys channels by — but a Concord channel is scoped to a + * *community* (whose secret unlocks it), not a host relay. + */ +data class ConcordChannelId( + val communityId: HexKey, + val channelId: HexKey, +) : Comparable { + fun toKey(): String = "$channelId@$communityId" + + override fun compareTo(other: ConcordChannelId): Int = toKey().compareTo(other.toKey()) + + override fun toString(): String = toKey() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt new file mode 100644 index 0000000000..65dd178f9c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +import com.vitorpamplona.quartz.concord.crypto.GroupKey + +/** + * Channel Chat Plane key derivation (CORD-03). + * + * Both channel types use the same `group_key("concord/channel", secret, + * channel_id, epoch)` derivation; only the secret and epoch differ: + * - **Public** channels derive from the shared `community_root` at the current + * root epoch — every member can derive them, so no key is distributed. + * - **Private** channels derive from their own random `channel_key` at the + * channel's own epoch — the key is delivered on role grant and rotated on + * revocation. + * + * The `channel_id` is folded into the derivation so every channel gets a distinct + * address regardless of the secret source, and it stays constant across + * visibility conversions and epoch rotations. + */ +object ConcordChannelKeys { + /** Public channel: derived from the community root at the root epoch. */ + fun publicChannel( + communityRoot: ByteArray, + channelId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, communityRoot, channelId, rootEpoch) + + /** Private channel: derived from its own channel key at the channel epoch. */ + fun privateChannel( + channelKey: ByteArray, + channelId: ByteArray, + channelEpoch: Long, + ): GroupKey = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, channelKey, channelId, channelEpoch) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d1f517ad72 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +/** + * The Concord Chat Plane binding, added to any standard event (kind 9 chat, 7 + * reaction, 5 delete, …) that is published on a channel plane. Because the binding + * layers onto reused standard events, these extensions are generic over the event + * type instead of pinned to a Concord-specific one. + */ +fun TagArrayBuilder.channel(channelId: HexKey) = addUnique(ChannelTag.assemble(channelId)) + +fun TagArrayBuilder.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch)) + +/** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */ +fun TagArrayBuilder.channelBinding( + channelId: HexKey, + epoch: Long, +) = apply { + channel(channelId) + epoch(epoch) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt new file mode 100644 index 0000000000..7158224348 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** The channel id this Chat Plane rumor is bound to, or null if unbound. */ +fun TagArray.concordChannel(): HexKey? = firstNotNullOfOrNull(ChannelTag::parse) + +/** The epoch this Chat Plane rumor is bound to, or null if unbound/malformed. */ +fun TagArray.concordEpoch(): Long? = firstNotNullOfOrNull(EpochTag::parse) + +/** + * True when these tags bind to exactly [channelId] and [epoch]. Recipients must + * reject any Chat Plane event whose binding does not match the plane it arrived on. + */ +fun TagArray.isConcordBoundTo( + channelId: HexKey, + epoch: Long, +): Boolean = concordChannel() == channelId && concordEpoch() == epoch diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt new file mode 100644 index 0000000000..29897734c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["channel", ]` tag that binds a Concord Chat Plane rumor to the channel + * it belongs to (CORD-03). Present on every message/reply/reaction/edit/delete so a + * recipient can reject an event lifted from another channel. + */ +class ChannelTag { + companion object { + const val TAG_NAME = "channel" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(channelId: HexKey) = arrayOf(TAG_NAME, channelId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt new file mode 100644 index 0000000000..804697e196 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["epoch", ]` tag that binds a Concord Chat Plane rumor to the community + * epoch it was authored under (CORD-03). Paired with [ChannelTag]; an event whose + * epoch does not match the plane it arrived on is rejected, so a message can't be + * replayed across a rekey. + */ +class EpochTag { + companion object { + const val TAG_NAME = "epoch" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(epoch: Long) = arrayOf(TAG_NAME, epoch.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt new file mode 100644 index 0000000000..7ddf32576d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Resolves the owner-rooted authority state of a Concord community from its + * folded Control Plane (CORD-04). + * + * "The Roster is owner-rooted: every Grant and Role is signed by an npub the + * Roster ranks strictly above it, and the chain terminates at the owner." + * + * Build one with [resolve] from the current entity heads (the values of + * [EditionFold.fold]) plus the community's known owner pubkey. It then answers: + * - [rank] — a member's authority (lower is higher; owner is [OWNER_RANK]; a + * member with no validly-granted role has no rank). + * - [effectivePermissions] — the union of a member's roles' bits (owner: all). + * - [isBanned] — membership in the healed banlist union. + * - [canActOn] — whether an actor may take a permissioned action on a target: + * the actor must hold the bit, must strictly outrank the target (equal cannot + * act on equal), and the owner is unremovable. + * + * Grants are validated by a fixpoint that only ever empowers members reachable + * from the owner: a Grant is honored when its signer already outranks every + * assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never + * touch the owner can never bootstrap themselves. + */ +class AuthorityResolver private constructor( + private val ownerLower: String, + private val roles: Map, + private val memberRoles: Map>, + private val banned: Set, +) { + /** The resolved role definitions (authority-gated), keyed by role id. Safe for display. */ + fun roles(): Map = roles + + /** The role definitions [pubKey] currently holds (empty for the owner and plain members). */ + fun rolesFor(pubKey: String): List = rolesOf(pubKey).mapNotNull { roles[it] } + + fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower + + fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned + + /** The role ids a member currently holds (empty for the owner and for plain members). */ + fun rolesOf(pubKey: String): Set = memberRoles[pubKey.lowercase()] ?: emptySet() + + /** + * The set of pubkeys that hold at least one validly-granted role (lowercase + * hex). This is the *privileged* roster — admins/moderators and any other + * role-holders — and excludes the owner and silent key-holding members, since + * plain membership is key possession and leaves no Control-Plane trace. + */ + fun roleHolders(): Set = memberRoles.keys + + /** The healed banlist union (lowercase hex). */ + fun bannedMembers(): Set = banned + + /** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */ + fun rank(pubKey: String): Long? { + val m = pubKey.lowercase() + if (m == ownerLower) return OWNER_RANK + val held = memberRoles[m] ?: return null + return held.mapNotNull { roles[it]?.position }.minOrNull() + } + + /** The union of a member's roles' permission bits (owner holds every bit). */ + fun effectivePermissions(pubKey: String): ConcordPermissions { + val m = pubKey.lowercase() + if (m == ownerLower) return ConcordPermissions.ALL + val held = memberRoles[m] ?: return ConcordPermissions.NONE + var acc = ConcordPermissions.NONE + for (id in held) roles[id]?.let { acc = acc union it.permissionBits() } + return acc + } + + /** True if the member holds [bit] and is not banned. */ + fun hasPermission( + pubKey: String, + bit: Int, + ): Boolean = !isBanned(pubKey) && effectivePermissions(pubKey).has(bit) + + /** + * Whether [actor] may take the action guarded by permission [bit] against + * [target]. Requires: actor not banned, actor holds [bit], the owner is never + * a valid target (unremovable), and actor strictly outranks target — "equal + * cannot act on equal". + */ + fun canActOn( + actor: String, + target: String, + bit: Int, + ): Boolean { + if (!hasPermission(actor, bit)) return false + if (isOwner(target)) return false + val actorRank = rank(actor) ?: return false + val targetRank = rank(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority + return actorRank < targetRank + } + + companion object { + /** The owner's rank — supreme and unremovable. No Role may claim it. */ + const val OWNER_RANK = 0L + + fun resolve( + editions: Collection, + ownerPubKey: String, + ): AuthorityResolver { + val ownerLower = ownerPubKey.lowercase() + + // Chains grouped by entity: one role chain per role id, one grant chain per member + // coordinate. We fold each chain through AUTHORIZED editions only, so a rogue cannot + // supersede a legit edition by minting a higher version from an unprivileged key + // (CORD-04 §1: "an edition whose signer isn't authorized is dropped"). + val roleChains = editions.filter { it.entityKind == ControlEntityKind.ROLE }.groupBy { it.entityIdHex } + val grantChains = editions.filter { it.entityKind == ControlEntityKind.GRANT }.groupBy { it.entityIdHex } + + var roles: Map = emptyMap() + var memberRoles: Map> = emptyMap() + + // Authority helpers read the CURRENT (previous-pass) roster, so within a pass a granter's + // rank is judged by the chain already settled behind it — the owner-rooted resolution the + // spec requires ("the fold starts at the owner ... and resolves outward"). + fun rankOf(member: String): Long? { + if (member == ownerLower) return OWNER_RANK + val held = memberRoles[member] ?: return null + return held.mapNotNull { roles[it]?.position }.minOrNull() + } + + fun holdsManageRoles(member: String): Boolean { + if (member == ownerLower) return true + val held = memberRoles[member] ?: return false + return held.any { roles[it]?.permissionBits()?.has(ConcordPermissions.MANAGE_ROLES) == true } + } + + // Owner-rooted fixpoint: each pass only ever empowers members reachable from the owner, so + // the roster grows monotonically and settles. Bounded by the edition count as a backstop. + val maxPasses = editions.size + 1 + var pass = 0 + while (pass++ <= maxPasses) { + // Roles: a role edition is authorized when its author is the owner or holds MANAGE_ROLES. + // Fold each role chain through its authorized editions, then keep a live, ranked head. + val newRoles = HashMap() + for ((entity, chain) in roleChains) { + val head = + EditionFold.foldEntity(chain.filter { it.author.lowercase() == ownerLower || holdsManageRoles(it.author.lowercase()) }) + ?: continue + val r = ConcordJson.decodeOrNull(head.content) ?: continue + if (r.deleted || r.position < 1) continue // no role may claim the owner's position 0 + newRoles[entity] = r + } + + // Grants: an edition is authorized when its granter is the owner, or holds MANAGE_ROLES + // AND strictly outranks every role it hands out. Fold each member's grant chain through + // its authorized editions so a rogue higher-version grant is dropped, not honored. + val newMemberRoles = HashMap>() + for ((_, chain) in grantChains) { + val head = + EditionFold.foldEntity( + chain.filter { e -> + val granter = e.author.lowercase() + if (granter == ownerLower) return@filter true + if (!holdsManageRoles(granter)) return@filter false + val granterRank = rankOf(granter) ?: return@filter false + val g = ConcordJson.decodeOrNull(e.content) ?: return@filter false + // Must strictly outrank each assigned role that actually exists. + g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true } + }, + ) ?: continue + val g = ConcordJson.decodeOrNull(head.content) ?: continue + newMemberRoles[g.member.lowercase()] = g.roleIds.filter { newRoles.containsKey(it) }.toSet() + } + + if (newRoles == roles && newMemberRoles == memberRoles) break + roles = newRoles + memberRoles = newMemberRoles + } + + // The union of a member's roles' permission bits (owner holds every bit). + fun effectivePermissionsOf(member: String): ConcordPermissions { + if (member == ownerLower) return ConcordPermissions.ALL + val held = memberRoles[member] ?: return ConcordPermissions.NONE + var acc = ConcordPermissions.NONE + for (id in held) roles[id]?.let { acc = acc union it.permissionBits() } + return acc + } + + // Banlist: honored only from a signer holding BAN (or the owner). The banlist is a single + // replaced doc, so fold its chain to the head first — that honors a legitimate unban, which + // is a *chained* edition replacing the previous set (e.g. ban→unban). Then heal concurrent + // forks: two moderators who ban different abusers at the same chain version fork the doc, and + // folding to one head would silently drop the other's ban. Union in every authorized edition + // that is NOT an ancestor of the head — those are the parallel bans the chain never absorbed. + // Ancestors (superseded by the chain, including an unban's now-cleared target) are already + // reflected by the head and must not be resurrected. This is CORD-06's "down-only healing": + // a concurrent ban is never lost, while an on-chain unban still takes effect. + val authorizedBanlist = + editions.filter { + it.entityKind == ControlEntityKind.BANLIST && + (it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN)) + } + val banned = HashSet() + val banHead = EditionFold.foldEntity(authorizedBanlist) + if (banHead != null) { + ConcordJson.decodeBanlist(banHead.content)?.forEach { banned.add(it.lowercase()) } + val ancestry = banlistAncestry(banHead, authorizedBanlist) + for (edition in authorizedBanlist) { + if (edition.hashHex !in ancestry) { + ConcordJson.decodeBanlist(edition.content)?.forEach { banned.add(it.lowercase()) } + } + } + } + + return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned) + } + + /** + * The set of edition hashes on [head]'s back-chain (head itself plus every edition it chains + * from via `prevHash`), among [pool]. Used to tell a superseded ancestor (already reflected by + * the head) from a concurrent fork (a parallel ban to heal). The `add`-guarded walk also + * terminates on any cycle. + */ + private fun banlistAncestry( + head: ControlEdition, + pool: List, + ): Set { + val byHash = pool.associateBy { it.hashHex } + val acc = HashSet() + var cur: ControlEdition? = head + while (cur != null && acc.add(cur.hashHex)) { + val prev = cur.prevHash?.toHexKey() + cur = if (prev != null) byHash[prev] else null + } + return acc + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt new file mode 100644 index 0000000000..13782071cf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import kotlin.jvm.JvmInline + +/** + * A member's Concord permission set (CORD-04): a `u64` bitfield of frozen bit + * positions. On the wire it is encoded as a **decimal string** (not a JSON + * number) to prevent floating-point corruption of the high bits. + * + * Bit positions are frozen forever — new permissions claim the next free bit, + * retired ones are burned and never reused. Bit 7 is retired; bits 10–12 are + * reserved. + * + * "A member's effective permissions are the union of their Roles' bits." + */ +@JvmInline +value class ConcordPermissions( + val bits: ULong, +) { + fun has(bit: Int): Boolean = (bits and (1uL shl bit)) != 0uL + + /** True if every bit set in [other] is also set here (used for role-vs-actor checks). */ + fun hasAll(other: ConcordPermissions): Boolean = (bits and other.bits) == other.bits + + infix fun union(other: ConcordPermissions): ConcordPermissions = ConcordPermissions(bits or other.bits) + + fun with(bit: Int): ConcordPermissions = ConcordPermissions(bits or (1uL shl bit)) + + fun without(bit: Int): ConcordPermissions = ConcordPermissions(bits and (1uL shl bit).inv()) + + /** The wire encoding: an unsigned decimal string with no leading zeros. */ + fun toWire(): String = bits.toString() + + companion object { + val NONE = ConcordPermissions(0uL) + + /** Every bit set — the owner's implicit, supreme permission set. */ + val ALL = ConcordPermissions(ULong.MAX_VALUE) + + // Frozen bit positions (CORD-04 §Permission Bits). + const val MANAGE_ROLES = 0 + const val MANAGE_CHANNELS = 1 + const val MANAGE_METADATA = 2 + const val KICK = 3 + const val BAN = 4 + const val MANAGE_MESSAGES = 5 + const val CREATE_INVITE = 6 + + // bit 7 retired — never reuse + + const val VIEW_AUDIT_LOG = 8 + const val MENTION_EVERYONE = 9 + + // bits 10-12 reserved + + fun of(vararg bits: Int): ConcordPermissions { + var acc = 0uL + for (b in bits) acc = acc or (1uL shl b) + return ConcordPermissions(acc) + } + + /** + * Parses the wire form — a decimal `u64` string. Returns [NONE] for a + * blank value; throws [NumberFormatException] for a malformed or + * out-of-range one so a corrupt edition is rejected rather than folded. + */ + fun fromWire(wire: String): ConcordPermissions { + if (wire.isBlank()) return NONE + return ConcordPermissions(wire.trim().toULong()) + } + + /** Non-throwing variant: returns null on a malformed value. */ + fun fromWireOrNull(wire: String): ConcordPermissions? = + try { + fromWire(wire) + } catch (_: NumberFormatException) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt new file mode 100644 index 0000000000..3183f41d2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord04Roles.control.eid +import com.vitorpamplona.quartz.concord.cord04Roles.control.ev +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk +import com.vitorpamplona.quartz.concord.crypto.EditionHash +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * The exact Grant edition an actor claims authority under (the `vac` tag, + * CORD-04). Verifiers block until they have synced this Grant, then resolve the + * actor's rank against it — a demoted member's stale citation is dropped. + */ +class AuthorityCitation( + val grantId: ByteArray, + val grantVersion: Long, + val grantHash: ByteArray, +) + +/** + * A single Control Plane edition (a kind-3308 author rumor, CORD-02/04). + * + * Editions are versioned, chainable state: each carries an entity id ([entityId], + * `eid`), a monotonically increasing [version] (`ev`), the [prevHash] of the + * previous edition (`ep`, absent for the genesis edition), an optional + * [authorityCitation] (`vac`) pinning the Grant the [author] acts under, and the + * verbatim entity [content]. Its identity is [hash] — a domain-separated hash of + * exactly those fields (see [EditionHash]) — which the next edition cites in `ep`, + * forming an unforgeable chain. + */ +class ControlEdition( + val entityKind: ControlEntityKind, + val entityId: ByteArray, + val version: Long, + val prevHash: ByteArray?, + val authorityCitation: AuthorityCitation?, + val content: String, + /** The actor's real pubkey (the seal/rumor author). */ + val author: String, + /** The rumor's event id — the deterministic tie-break key at equal version. */ + val rumorId: String, + val createdAt: Long, +) { + /** Domain-separated edition identity; the next edition's `ep` cites this. */ + val hash: ByteArray by lazy { EditionHash.hash(entityId, version, prevHash, content) } + + val entityIdHex: String get() = entityId.toHexKey() + val hashHex: String get() = hash.toHexKey() + + companion object { + /** + * Parses a decrypted, verified kind-3308 [rumor] (its [author] is the + * rumor's pubkey) into a [ControlEdition], or returns null if it is not a + * well-formed control edition (unknown/absent `vsk`, missing `eid`/`ev`, + * malformed hex, …) so the caller drops it rather than folding garbage. + * Reads the typed tags of [ControlEditionEvent]. + */ + fun fromRumor( + rumor: Event, + author: String = rumor.pubKey, + ): ControlEdition? { + if (rumor.kind != ControlEditionEvent.KIND) return null + val entityKind = rumor.tags.vsk() ?: return null + val entityId = rumor.tags.eid() ?: return null + val version = rumor.tags.ev() ?: return null + + // A present-but-malformed `ep` is a corrupt edition (reject); an absent (or blank) + // `ep` is the genesis edition (no previous hash). + val epTag = rumor.tags.firstOrNull { it.size >= 2 && it[0] == EpTag.TAG_NAME && it[1].isNotBlank() } + val prevHash = if (epTag == null) null else EpTag.parse(epTag) ?: return null + + // Likewise a present-but-malformed `vac` is rejected; absent means owner-authored. + val vacTag = rumor.tags.firstOrNull { it.size >= 4 && it[0] == VacTag.TAG_NAME } + val vac = if (vacTag == null) null else VacTag.parse(vacTag) ?: return null + + return ControlEdition( + entityKind = entityKind, + entityId = entityId, + version = version, + prevHash = prevHash, + authorityCitation = vac, + content = rumor.content, + author = author, + rumorId = rumor.id, + createdAt = rumor.createdAt, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt new file mode 100644 index 0000000000..1998255221 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** + * Builds unsigned kind-3308 Control Plane edition rumors (the inverse of + * [ControlEdition.fromRumor]). Seal these with a plaintext (20014) seal and wrap + * them on the community's Control Plane so the author's signature survives + * re-encryption across epochs. Reuses [ControlEditionEvent.build] for the wire + * shape (its typed `vsk`/`eid`/`ev`/`ep`/`vac` tags); this only stamps the author. + */ +object ControlEditionBuilder { + /** + * Assembles a control edition rumor for [entityKind]/[entityId] at [version]. + * Pass [prevHash] to chain onto the previous edition (null for genesis) and + * [authorityCitation] to pin the Grant the [authorPubKey] acts under. + */ + fun rumor( + authorPubKey: HexKey, + entityKind: ControlEntityKind, + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: String, + createdAt: Long, + authorityCitation: AuthorityCitation? = null, + ): Event = + RumorAssembler.assembleRumor( + authorPubKey, + ControlEditionEvent.build(entityKind, entityId, version, content, prevHash, authorityCitation, createdAt), + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt new file mode 100644 index 0000000000..adacb499e6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +/** + * JSON facility for Concord Control Plane entity content. Unknown keys are + * ignored because entity shapes are deliberately client-extensible (CORD-03/04), + * and defaults are lenient so a partial entity never throws mid-fold. + */ +object ConcordJson { + val instance = + Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } + + inline fun decodeOrNull(content: String): T? = + try { + instance.decodeFromString(content) + } catch (_: Exception) { + null + } + + /** Parses a Banlist edition's content (a bare JSON array of hex pubkeys). */ + fun decodeBanlist(content: String): List? = + try { + instance.decodeFromString(ListSerializer(String.serializer()), content) + } catch (_: Exception) { + null + } +} + +/** + * A Role's scope (CORD-04 §2): server-wide (`{"kind":"server"}`) or restricted to a + * single channel (`{"kind":"channel","channel_id":""}`). It is an **object** on + * the wire, pinned to the Concord v2 reference client — NOT a bare string. Typing it as + * a `String` (the old bug) makes the whole [RoleEntity] fail to decode, which silently + * drops the role, and with it every authority (grant) that depends on it. + */ +@Serializable +class RoleScope( + val kind: String = "server", + @SerialName("channel_id") val channelId: String? = null, +) + +/** + * A Role's content (CORD-04): a named bundle of permissions at a [position]. + * The role's id is the edition's entity id, not a content field. Lower [position] + * ranks higher; no role may claim position 0 (reserved for the owner). + */ +@Serializable +class RoleEntity( + val name: String = "", + val position: Long = 0, + /** u64 permission bitfield as a decimal string. */ + val permissions: String = "0", + /** Server-wide, or a single channel — an object (see [RoleScope]). Null = server. */ + val scope: RoleScope? = null, + val color: Long = 0, + val deleted: Boolean = false, +) { + fun permissionBits(): ConcordPermissions = ConcordPermissions.fromWireOrNull(permissions) ?: ConcordPermissions.NONE +} + +/** + * A Grant's content (CORD-04): maps a [member] to the set of [roleIds] they hold. + * Honored only if the granting actor outranks every assigned Role and the chain + * terminates at the owner (see [AuthorityResolver]). + */ +@Serializable +class GrantEntity( + val member: String = "", + @SerialName("role_ids") val roleIds: List = emptyList(), +) + +/** + * A Channel's content (CORD-03). The channel id is the edition entity id. + * [private] selects derived-key visibility; [voice] flags an audio channel. + * A [deleted] channel is terminal — its id is never reused. + */ +@Serializable +class ChannelEntity( + val name: String = "", + val private: Boolean = false, + val voice: Boolean = false, + val deleted: Boolean = false, +) + +/** + * A community's Metadata content (CORD-02): display [name], optional [description], the community's + * bootstrap [relays], and the encrypted-media [icon]/[banner] pointers. Client-extensible. + * + * [icon]/[banner] are CORD-02 §6 [ImagePointer]s (an object `{url,key,nonce,hash}`), NOT plain URLs — + * the wire shape is pinned to the Concord v2 reference client. Deserializing them into anything else + * (e.g. a `String`) fails the whole entity's decode, which is why a wrong type silently drops the + * community name too. + */ +@Serializable +class MetadataEntity( + val name: String = "", + val icon: ImagePointer? = null, + val banner: ImagePointer? = null, + val description: String? = null, + val relays: List = emptyList(), +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt new file mode 100644 index 0000000000..f399690a1e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +/** + * The Control Plane entity sub-kinds (the `vsk` tag on a kind-3308 edition), + * pinned to the Concord v2 reference client (`concord-v2/lib/kinds.ts`). + * + * On the wire the `vsk` value is a decimal string with no leading zeros + * ([wire]); [ControlEntityKind.of] maps that string back to the enum. + */ +enum class ControlEntityKind( + val wire: String, +) { + /** Community metadata (name, icon, description). */ + METADATA("0"), + + /** A Role definition (name, position, permissions, scope, color). */ + ROLE("1"), + + /** A Channel definition (name, private flag, voice flag). */ + CHANNEL("2"), + + /** A member→roles Grant. */ + GRANT("3"), + + /** The community-wide Banlist. */ + BANLIST("4"), + + /** A live invite-link registry entry (Public/Private source of truth). */ + INVITE_LIVE("6"), + + /** The aggregate invite registry. */ + INVITE_REGISTRY("8"), + + /** A revoked invite-link marker. */ + INVITE_REVOKED("9"), + + /** The dissolution tombstone (terminal). */ + DISSOLVED("10"), + ; + + companion object { + private val byWire = entries.associateBy { it.wire } + + fun of(wire: String): ControlEntityKind? = byWire[wire] + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt new file mode 100644 index 0000000000..3b8e3ff2ea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Folds Control Plane editions into the current head of each entity (CORD-04 + * §Edition Hashing & Chain Integrity). + * + * Rules enforced here: + * - **Genesis anchoring** — a chain starts at the lowest-version edition with no + * `ep` (prev hash). + * - **Refounding fallback (fresh joiner)** — when no genesis is present, anchor + * at the lowest-version edition available and accept it as the baseline. After + * a Refounding (CORD-06 §3) the compacted head still carries the `ep` it had + * before compaction, citing an edition in the *prior* epoch that a fresh joiner + * never fetches — so a dangling `prev` is the norm, not corruption, and CORD-04 + * §1 ("Folding across a Refounding") requires the joiner to take that head as + * its baseline. The signature + owner-rooted authority check (applied by + * [com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver] on top of this + * structural fold) is the whole test, so an unrooted forgery is still dropped + * there. Amethyst always re-folds the whole buffer from scratch, so it is + * structurally always a fresh joiner; it holds no prior chain to fail closed on. + * - **Intact chain / no downgrades** — the head advances to `version + 1` only + * when that edition's `ep` cites the current head's [ControlEdition.hash]. + * Lower or non-chaining versions are ignored. + * - **Deterministic convergence** — at equal version, ties break on the lower + * rumor id, so every honest client folds to the same head. + * + * Authority-weighted tie-break ("authority first, then the lower rumor id") and + * the owner-rooted `vac` verification are applied by the resolver layer on top of + * this structural fold; this class is purely the chain walk. + */ +object EditionFold { + /** Groups mixed [editions] by entity id and folds each to its head. */ + fun fold(editions: Collection): Map { + val byEntity = editions.groupBy { it.entityIdHex } + val out = HashMap(byEntity.size) + for ((entity, list) in byEntity) { + foldEntity(list)?.let { out[entity] = it } + } + return out + } + + /** Folds the editions of a single entity into its current head, or null. */ + fun foldEntity(editions: List): ControlEdition? { + if (editions.isEmpty()) return null + + // Index editions by version, keeping the tie-break winner where several + // share a version (lower rumor id wins). + val byVersion = HashMap>() + for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) + + // Anchor at the genesis (lowest version with no prev hash), preferring the + // tie-break winner. When no genesis is present — the compacted head of a + // Refounded community carries a prev citing the prior epoch — a fresh joiner + // anchors at the lowest-version edition it does hold and accepts it as the + // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. + var head = + editions + .filter { it.prevHash == null } + .minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: return null + + // Walk the chain upward while the next version chains from the current head. + while (true) { + val next = + byVersion[head.version + 1] + ?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex } + ?.minByOrNull { it.rumorId } + ?: break + head = next + } + return head + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt new file mode 100644 index 0000000000..4713d0c672 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A kind-3308 Control Plane edition (CORD-02/04) — versioned, chainable community + * state (metadata, roles, channels, grants, banlist, invites, …). Authored as an + * unsigned rumor, plaintext-sealed and wrapped on the community's Control Plane so + * the author signature survives re-encryption across epochs. + * + * The wire shape is the entity content plus the `vsk`/`eid`/`ev`/`ep`/`vac` + * binding tags (see this package's `tags/`). The folded domain view — with the + * derived [com.vitorpamplona.quartz.concord.crypto.EditionHash] chain — is + * [com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition], which reads these + * accessors. + */ +class ControlEditionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun entityKind() = tags.vsk() + + fun entityId() = tags.eid() + + fun version() = tags.ev() + + fun prevHash() = tags.ep() + + fun authorityCitation() = tags.vac() + + companion object { + const val KIND = 3308 + + /** + * Builds the edition template for [entityKind]/[entityId] at [version]. + * Tags are emitted in the fixed `vsk, eid, ev, ep?, vac?` order the chain's + * rumor ids depend on. Assemble it into a rumor with the author pubkey via + * `RumorAssembler`. + */ + fun build( + entityKind: ControlEntityKind, + entityId: ByteArray, + version: Long, + content: String, + prevHash: ByteArray? = null, + authorityCitation: AuthorityCitation? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + vsk(entityKind) + eid(entityId) + ev(version) + prevHash?.let { ep(it) } + authorityCitation?.let { vac(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..3882ee7101 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayBuilderExt.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EvTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.vsk(kind: ControlEntityKind) = addUnique(VskTag.assemble(kind)) + +fun TagArrayBuilder.eid(entityId: ByteArray) = addUnique(EidTag.assemble(entityId)) + +fun TagArrayBuilder.ev(version: Long) = addUnique(EvTag.assemble(version)) + +fun TagArrayBuilder.ep(prevHash: ByteArray) = addUnique(EpTag.assemble(prevHash)) + +fun TagArrayBuilder.vac(citation: AuthorityCitation) = addUnique(VacTag.assemble(citation)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayExt.kt new file mode 100644 index 0000000000..e6fd30cf29 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EvTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** The Control Plane entity kind (`vsk`) this edition updates, or null if absent/unknown. */ +fun TagArray.vsk(): ControlEntityKind? = firstNotNullOfOrNull(VskTag::parse) + +/** The 32-byte entity id (`eid`), or null if absent/malformed. */ +fun TagArray.eid(): ByteArray? = firstNotNullOfOrNull(EidTag::parse) + +/** The edition version (`ev`), or null if absent/malformed/negative. */ +fun TagArray.ev(): Long? = firstNotNullOfOrNull(EvTag::parse) + +/** The previous-edition hash (`ep`), or null if absent (genesis) — see also the presence check in `fromRumor`. */ +fun TagArray.ep(): ByteArray? = firstNotNullOfOrNull(EpTag::parse) + +/** The authority citation (`vac`), or null if absent (owner-authored). */ +fun TagArray.vac(): AuthorityCitation? = firstNotNullOfOrNull(VacTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EidTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EidTag.kt new file mode 100644 index 0000000000..f9cb22b35b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EidTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["eid", ]` entity-id tag: the 32-byte id of the Control Plane entity a + * kind-3308 edition updates (its version chain is keyed by this id). + */ +class EidTag { + companion object { + const val TAG_NAME = "eid" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ByteArray? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } + } + + fun assemble(entityId: ByteArray) = arrayOf(TAG_NAME, entityId.toHexKey()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EpTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EpTag.kt new file mode 100644 index 0000000000..0dc3c9464d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EpTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["ep", ]` edition-prev tag: the 32-byte [hash][com.vitorpamplona.quartz.concord.crypto.EditionHash] + * of the previous edition in this entity's chain. Absent on the genesis edition. + */ +class EpTag { + companion object { + const val TAG_NAME = "ep" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ByteArray? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } + } + + fun assemble(prevHash: ByteArray) = arrayOf(TAG_NAME, prevHash.toHexKey()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt new file mode 100644 index 0000000000..edaf67d3ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `["ev", ]` edition-version tag: the monotonically increasing version of a kind-3308 edition. */ +class EvTag { + companion object { + const val TAG_NAME = "ev" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toLongOrNull()?.takeIf { it >= 0 } + } + + fun assemble(version: Long) = arrayOf(TAG_NAME, version.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt new file mode 100644 index 0000000000..486b1cd3b2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["vac", , , ]` versioned-authority-citation + * tag: the exact Grant edition a delegated actor claims authority under (CORD-04). + * Absent when the owner authors the edition. Carries three values, so it needs the + * full tag (not just a value) to round-trip. + */ +class VacTag { + companion object { + const val TAG_NAME = "vac" + + fun isTag(tag: Array) = tag.has(3) && tag[0] == TAG_NAME + + fun parse(tag: Array): AuthorityCitation? { + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val grantId = tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null + val grantVersion = tag[2].toLongOrNull() ?: return null + val grantHash = tag[3].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null + return AuthorityCitation(grantId, grantVersion, grantHash) + } + + fun assemble(citation: AuthorityCitation) = + arrayOf( + TAG_NAME, + citation.grantId.toHexKey(), + citation.grantVersion.toString(), + citation.grantHash.toHexKey(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VskTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VskTag.kt new file mode 100644 index 0000000000..8d5fc6c974 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VskTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["vsk", ]` versioned-sub-kind tag: which Control Plane entity a + * kind-3308 edition updates (metadata `0`, role `1`, channel `2`, grant `3`, + * banlist `4`, …). See [ControlEntityKind]. + */ +class VskTag { + companion object { + const val TAG_NAME = "vsk" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ControlEntityKind? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return ControlEntityKind.of(tag[1]) + } + + fun assemble(kind: ControlEntityKind) = arrayOf(TAG_NAME, kind.wire) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt new file mode 100644 index 0000000000..2cafcfe5f3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** A channel grant carried in an invite: its id, delivered [key], [epoch], and [name]. */ +@Serializable +class InviteChannel( + val id: String, + val key: String, + val epoch: Long, + val name: String = "", +) + +/** + * The contents of a Concord invite (CORD-05) — everything a joiner needs to + * become a member: the self-certifying [communityId] with its [owner]/[ownerSalt] + * proof, the access [communityRoot] at [rootEpoch], per-[channels] grants, + * bootstrap [relays], display [name]/[icon], optional [expiresAt] and creator + * attribution. + * + * Field names are pinned to the Concord v2 reference client (snake_case on the + * wire) so bundles interoperate. This object is JSON-serialized and encrypted — + * into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites). + */ +@Serializable +class CommunityInvite( + @SerialName("community_id") val communityId: String, + val owner: String, + @SerialName("owner_salt") val ownerSalt: String, + @SerialName("community_root") val communityRoot: String, + @SerialName("root_epoch") val rootEpoch: Long = 0, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", + val icon: ImagePointer? = null, + @SerialName("expires_at") val expiresAt: Long? = null, + @SerialName("creator_npub") val creatorNpub: String? = null, + val label: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt new file mode 100644 index 0000000000..910eaedb68 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * Direct invites (CORD-05): for a known npub, the invite skips the public bundle + * and is delivered as a standard NIP-59 giftwrap — a kind-3313 rumor carrying the + * [CommunityInvite], sealed (kind 13) to the recipient and wrapped (kind 1059) + * with `["p", recipient]` and a `["k", "3313"]` index tag so the recipient can + * query for pending invites without decrypting every giftwrap. + * + * It cannot be revoked — the recipient holds the keys the moment it lands. + */ +object ConcordDirectInvite { + const val KIND: Int = 3313 + const val TAG_P = "p" + const val TAG_K = "k" + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + + /** + * Builds a giftwrapped direct invite from [senderSigner] to [recipientPubKey]. + * Returns the kind-1059 wrap to publish to the recipient's inbox relays. + */ + suspend fun build( + senderSigner: NostrSigner, + recipientPubKey: HexKey, + invite: CommunityInvite, + createdAt: Long, + ): GiftWrapEvent { + val rumor = RumorAssembler.assembleRumor(senderSigner.pubKey, createdAt, KIND, emptyArray(), json(invite)) + val seal = SealedRumorEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) + + // Wrap with a random ephemeral key, adding the ["k","3313"] index tag. + val wrapSigner = NostrSignerInternal(KeyPair()) + val content = wrapSigner.nip44Encrypt(seal.toJson(), recipientPubKey) + return wrapSigner.sign( + createdAt = createdAt, + kind = GiftWrapEvent.KIND, + tags = arrayOf(arrayOf(TAG_P, recipientPubKey), arrayOf(TAG_K, KIND.toString())), + content = content, + ) + } + + /** + * Opens a direct-invite giftwrap addressed to [recipientSigner] and returns the + * [CommunityInvite], or null if it isn't a valid direct invite for this user. + * Callers should still [ConcordInviteBundle.validate] the result. + */ + suspend fun parse( + wrap: GiftWrapEvent, + recipientSigner: NostrSigner, + ): CommunityInvite? { + val seal = wrap.unwrapOrNull(recipientSigner) ?: return null + if (seal !is SealedRumorEvent) return null + val rumor = seal.unsealOrNull(recipientSigner) ?: return null + if (rumor.kind != KIND) return null + return ConcordJson.decodeOrNull(rumor.content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt new file mode 100644 index 0000000000..88fc9e9702 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.utils.RandomInstance + +/** A freshly minted public invite link: the shareable URL, the link keys, and the bundle to publish. */ +class MintedInviteLink( + val url: String, + val linkSignerPubKey: String, + val linkSignerPrivKey: ByteArray, + val token: ByteArray, + val bundleEvent: Event, +) + +/** + * The public invite bundle (CORD-05): a kind-33301 addressable event whose + * content is the [CommunityInvite] NIP-44-encrypted under the bundle key derived + * from the link's 16-byte unlock token. The event is signed by a per-link + * `link_signer` keypair (so re-posting refreshes keys) and tagged + * `["d",""],["vsk","6"]`. + * + * A server that indexes the naddr never holds the token, so it can never open the + * bundle. Pinned to the Concord v2 reference client. + */ +object ConcordInviteBundle { + const val KIND = ConcordInviteBundleEvent.KIND + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + + /** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */ + fun build( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event { + val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) + val content = Nip44.v2.encrypt(json(invite), bundleKey).encodePayload() + val signer = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)) + return signer.sign(ConcordInviteBundleEvent.build(content, createdAt)) + } + + /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ + fun parse( + event: Event, + token: ByteArray, + ): CommunityInvite? { + if (event.kind != KIND) return null + return try { + val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) + ConcordJson.decodeOrNull(Nip44.v2.decrypt(event.content, bundleKey)) + } catch (_: Exception) { + null + } + } + + /** + * Validates that an [invite]'s owner + salt actually reproduce its + * community_id (CORD-02 self-certification), so a bundle can't smuggle a false + * owner or a fake key for a real community. + */ + fun validate(invite: CommunityInvite): Boolean { + val owner = invite.owner.hexToByteArrayOrNull() ?: return false + val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false + return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId + } + + /** True if the invite has an expiry in the past (blocks joining; preview still renders). Time in unix ms. */ + fun isExpired( + invite: CommunityInvite, + nowMs: Long, + ): Boolean = invite.expiresAt?.let { it < nowMs } ?: false + + /** + * Mints a complete public invite link for [invite]: generates a fresh 16-byte + * token and a per-link signer, builds the bundle event and the shareable + * `{base}/invite/{naddr}#{fragment}` URL (with optional bootstrap [relays]). + */ + fun mintLink( + base: String, + invite: CommunityInvite, + createdAt: Long, + relays: List? = null, + ): MintedInviteLink { + val token = RandomInstance.bytes(16) + val linkSigner = KeyPair() + val bundleEvent = build(linkSigner.privKey!!, token, invite, createdAt) + val url = ConcordInviteLink.buildUrl(base, linkSigner.pubKey.toHexKey(), token, relays) + return MintedInviteLink(url, linkSigner.pubKey.toHexKey(), linkSigner.privKey, token, bundleEvent) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt new file mode 100644 index 0000000000..dff968e91f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** The decoded contents of an invite-link URL fragment. */ +class InviteFragment( + /** The 16-byte unlock token (derives the bundle key; never sent to a server). */ + val token: ByteArray, + /** The resolved bootstrap relay URLs (stock set, or the encoded custom list). */ + val relays: List, + val usedStockRelays: Boolean, +) + +/** A parsed invite-link URL: its addressable pointer plus the private fragment. */ +class ParsedInviteLink( + val naddr: String, + val linkSignerPubKey: String, + val kind: Int, + val fragment: InviteFragment, +) + +/** + * Codec for Concord invite links (CORD-05): + * + * ``` + * {base}/invite/{naddr}#{fragment} + * ``` + * + * The `naddr` is a public NIP-19 pointer to the kind-33301 bundle + * `(33301, link_signer_pubkey, d="")`. The `#fragment` is **never sent to any + * server**: it is base64url of `[version=4][flags][relays?][token:16]`, carrying + * the 16-byte unlock token (→ [com.vitorpamplona.quartz.concord.crypto + * .ConcordKeyDerivation.inviteBundleKey]) and, when flag `0x01` is unset, up to + * three bootstrap relays encoded against [InviteRelayDictionary]. + * + * Pinned to the Concord v2 reference client for interop. + */ +object ConcordInviteLink { + const val VERSION = 4 + const val FLAG_STOCK_RELAYS = 0x01 + const val MAX_RELAYS = 3 + + private const val MARKER_WSS_HOST = 0 + private const val MARKER_FULL_URL = 255 + private const val WSS_PREFIX = "wss://" + private const val TOKEN_LEN = 16 + + /** + * Encodes the fragment for [token] and optional [relays]. Passing null or the + * exact stock set uses flag `0x01` and emits no relay bytes; otherwise up to + * [MAX_RELAYS] relays are encoded (dictionary id, `wss://` host, or full URL). + */ + @OptIn(ExperimentalEncodingApi::class) + fun encodeFragment( + token: ByteArray, + relays: List? = null, + ): String { + require(token.size == TOKEN_LEN) { "token must be $TOKEN_LEN bytes, was ${token.size}" } + val out = ArrayList(2 + TOKEN_LEN) + out.add(VERSION.toByte()) + + val useStock = relays == null || relays == InviteRelayDictionary.STOCK + if (useStock) { + out.add(FLAG_STOCK_RELAYS.toByte()) + } else { + require(relays!!.size <= MAX_RELAYS) { "at most $MAX_RELAYS relays, was ${relays.size}" } + out.add(0) + out.add(relays.size.toByte()) + for (r in relays) { + val id = InviteRelayDictionary.idOf(r) + when { + id != null -> out.add(id.toByte()) + r.startsWith(WSS_PREFIX) -> { + val host = r.substring(WSS_PREFIX.length).encodeToByteArray() + require(host.size <= 255) { "relay host too long" } + out.add(MARKER_WSS_HOST.toByte()) + out.add(host.size.toByte()) + host.forEach { out.add(it) } + } + else -> { + val url = r.encodeToByteArray() + require(url.size <= 255) { "relay url too long" } + out.add(MARKER_FULL_URL.toByte()) + out.add(url.size.toByte()) + url.forEach { out.add(it) } + } + } + } + } + token.forEach { out.add(it) } + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(out.toByteArray()) + } + + /** + * Decodes an invite [fragment]. Throws for a malformed fragment or a version + * other than [VERSION] (lower = legacy, higher = newer than this client). + * Unknown dictionary ids are skipped rather than aborting the parse. + */ + @OptIn(ExperimentalEncodingApi::class) + fun decodeFragment(fragment: String): InviteFragment { + val bytes = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(fragment) + require(bytes.size >= 2 + TOKEN_LEN) { "fragment too short" } + val version = bytes[0].toInt() and 0xFF + require(version == VERSION) { "unsupported invite fragment version $version" } + val flags = bytes[1].toInt() and 0xFF + + var pos = 2 + val relays = ArrayList() + var usedStock = false + if (flags and FLAG_STOCK_RELAYS != 0) { + relays.addAll(InviteRelayDictionary.STOCK) + usedStock = true + } else { + val count = bytes[pos++].toInt() and 0xFF + repeat(count) { + val marker = bytes[pos++].toInt() and 0xFF + when (marker) { + MARKER_WSS_HOST -> { + val len = bytes[pos++].toInt() and 0xFF + relays.add(WSS_PREFIX + bytes.decodeToString(pos, pos + len)) + pos += len + } + MARKER_FULL_URL -> { + val len = bytes[pos++].toInt() and 0xFF + relays.add(bytes.decodeToString(pos, pos + len)) + pos += len + } + else -> InviteRelayDictionary.urlOf(marker)?.let { relays.add(it) } // unknown id: skip + } + } + } + + require(bytes.size - pos == TOKEN_LEN) { "trailing bytes are not a $TOKEN_LEN-byte token" } + return InviteFragment(bytes.copyOfRange(pos, pos + TOKEN_LEN), relays, usedStock) + } + + /** Builds a full shareable invite URL under [base]. */ + fun buildUrl( + base: String, + linkSignerPubKey: String, + token: ByteArray, + relays: List? = null, + ): String { + val naddr = NAddress.create(ConcordInviteBundleEvent.KIND, linkSignerPubKey, "", null) + val trimmed = base.trimEnd('/') + return "$trimmed/invite/$naddr#${encodeFragment(token, relays)}" + } + + /** Parses a full invite URL back into its pointer + fragment, or null if malformed. */ + fun parseUrl(url: String): ParsedInviteLink? { + val hash = url.indexOf('#') + if (hash < 0) return null + val fragment = + try { + decodeFragment(url.substring(hash + 1)) + } catch (_: Exception) { + return null + } + val marker = url.indexOf("/invite/") + if (marker < 0) return null + val naddr = url.substring(marker + "/invite/".length, hash) + val parsed = NAddress.parse(naddr) ?: return null + if (parsed.kind != ConcordInviteBundleEvent.KIND) return null + return ParsedInviteLink(naddr, parsed.author, parsed.kind, fragment) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt new file mode 100644 index 0000000000..701411e907 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +/** + * The invite-link relay dictionary (CORD-05 §Relay Dictionary), version 4, + * pinned to the Concord v2 reference client. Referencing a relay by its dictionary + * id keeps invite links compact; the stock set is selected by a single flag so the + * common invite carries zero relay bytes. + * + * Dictionary ids run 1–254. Id 0 is reserved as the "wss:// literal host" marker + * and 255 as the "full URL" marker in the fragment encoding (see [ConcordInviteLink]). + */ +object InviteRelayDictionary { + /** The stock relay set carried by flag 0x01 (the four v4 primaries). */ + val STOCK: List = + listOf( + "wss://jskitty.com/nostr", + "wss://asia.vectorapp.io/nostr", + "wss://relay.ditto.pub", + "wss://relay.dreamith.to", + ) + + /** id → relay url, for the ids that fit in a single dictionary byte (1–254). */ + val BY_ID: Map = + mapOf( + 1 to "wss://jskitty.com/nostr", + 2 to "wss://asia.vectorapp.io/nostr", + 3 to "wss://relay.ditto.pub", + 4 to "wss://relay.dreamith.to", + ) + + private val ID_BY_URL: Map = BY_ID.entries.associate { (id, url) -> url to id } + + fun idOf(url: String): Int? = ID_BY_URL[url] + + fun urlOf(id: Int): String? = BY_ID[id] +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt new file mode 100644 index 0000000000..8e17c755fb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites.bundle + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The public invite bundle (CORD-05): a kind-33301 **addressable** event whose + * content is a [com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite] + * NIP-44-encrypted under the bundle key derived from the link's 16-byte unlock + * token. Tagged `["d",""]` (so `link_signer` has exactly one live bundle) and + * `["vsk","6"]` ([ControlEntityKind.INVITE_LIVE]). + * + * A relay that indexes the naddr never holds the token, so it can never open the + * bundle. Minting/parsing/validation live in + * [com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle]; this is the + * wire event it builds and that [com.vitorpamplona.quartz.utils.EventFactory] parses. + */ +class ConcordInviteBundleEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** The versioned sub-kind marker (`vsk`), expected to be [ControlEntityKind.INVITE_LIVE]. */ + fun versionedSubKind() = tags.vsk() + + companion object { + const val KIND = 33301 + + /** Builds the addressable bundle template carrying the already-encrypted [encryptedInvite]. */ + fun build( + encryptedInvite: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, encryptedInvite, createdAt) { + dTag("") + addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt new file mode 100644 index 0000000000..7a7bb34899 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** + * The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current + * Control Plane, compacted to its per-entity head editions and re-sealed under the + * fresh [newRoot] at [newEpoch]) and the [rekeyWraps] (kind-3303 base-rotation + * blobs, sealed under the **prior** root, that deliver [newRoot] to every retained + * member and to nobody else). Publish [controlWraps] first (the new epoch's state) + * then [rekeyWraps] (the key that unlocks it). + */ +class RefoundingBuild( + val newRoot: ByteArray, + val newEpoch: Long, + val controlWraps: List, + val rekeyWraps: List, +) + +/** A retained member's decrypted rekey result: the [newRoot] delivered at [newEpoch] by [rotator]. */ +class ReceivedRefounding( + val newRoot: ByteArray, + val newEpoch: Long, + val rotator: HexKey, +) + +/** + * Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a + * removed member absolutely. Public Channels and the Control/Guestbook planes all + * derive from the root, so rolling it rotates every plane at once; Private Channels + * (independently keyed) are rekeyed separately and are not handled here. + * + * The builder is pure — the caller sources the retained-recipient set (from the + * Guestbook membership minus the removed/banned) and owns publish + persistence. + * All crypto is signer-based so a NIP-46 bunker owner can refound without exposing + * a raw key. + */ +object ConcordRefounding { + /** + * Builds a Refounding: compacts the Control Plane under [newRoot] and mints the + * base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly]. + * + * @param priorRoot the community_root being rotated out (at [rootEpoch]) + * @param newRoot the freshly generated 32-byte community_root + * @param priorControlWraps the current Control Plane's kind-1059 wraps (any subset that folds) + * @param priorControlKey the Control Plane group key at [rootEpoch] + * @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key + */ + suspend fun build( + rotatorSigner: NostrSigner, + communityId: ByteArray, + priorRoot: ByteArray, + newRoot: ByteArray, + rootEpoch: Long, + priorControlWraps: List, + priorControlKey: GroupKey, + recipientsXOnly: List, + createdAt: Long, + ): RefoundingBuild { + val newEpoch = rootEpoch + 1 + val newControlKey = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, newEpoch) + + val controlWraps = compactControlPlane(priorControlWraps, priorControlKey, newControlKey) + + val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) + val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() + val rekeyWraps = + buildBaseRekeyWraps( + rotatorSigner = rotatorSigner, + baseRekeyKey = baseRekeyKey, + recipientsXOnly = recipientsXOnly, + newRoot = newRoot, + newEpoch = newEpoch, + prevEpoch = rootEpoch, + prevCommit = prevCommit, + createdAt = createdAt, + ) + + return RefoundingBuild(newRoot, newEpoch, controlWraps, rekeyWraps) + } + + /** + * Compacts [priorWraps] into a slim snapshot re-published under [newControlKey] + * (CORD-06 §3): keep only the head (highest-version) edition per entity and + * re-wrap its **original plaintext seal** — which carries the original author's + * signature — under the new root. Because Control Plane seals are plaintext + * (CORD-02 §5), re-encryption preserves those signatures, so a fresh joiner + * verifies the compacted state exactly as it verified the full chain. + */ + fun compactControlPlane( + priorWraps: List, + priorControlKey: GroupKey, + newControlKey: GroupKey, + ): List { + // entity coordinate -> (head edition, its verified seal) + val heads = HashMap>() + for (wrap in priorWraps) { + val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKey) ?: continue + val edition = ControlEdition.fromRumor(opened.rumor) ?: continue + val coord = edition.entityKind.wire + ":" + edition.entityIdHex + val current = heads[coord] + if (current == null || edition.version > current.first.version) { + heads[coord] = edition to opened.seal + } + } + return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKey, createdAt = seal.createdAt) } + } + + /** + * Mints the base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly], + * chunked at [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal, + * rotator-signed) on the [baseRekeyKey] address so every current member — who + * precomputes that address from the prior root — receives it live. + */ + suspend fun buildBaseRekeyWraps( + rotatorSigner: NostrSigner, + baseRekeyKey: GroupKey, + recipientsXOnly: List, + newRoot: ByteArray, + newEpoch: Long, + prevEpoch: Long, + prevCommit: HexKey, + createdAt: Long, + ): List { + if (recipientsXOnly.isEmpty()) return emptyList() + val blobs = + recipientsXOnly.map { recipient -> + ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), ConcordRekey.ROOT_SCOPE, newEpoch, newRoot) + } + val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK) + val total = chunks.size + return chunks.mapIndexed { index, chunk -> + val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index, total) + val rumor = RumorAssembler.assembleRumor(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk)) + ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt) + } + } + + /** + * Receives a base rotation for the member behind [recipientSigner]: opens the + * kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]), + * verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit` + * continues the [priorRoot] the member holds, and returns the delivered new root + * (with the rotator's real pubkey, so the caller can authorize it against the + * folded roster). Null if no chunk carries this member's blob — which only means + * "removed" once the caller confirms it holds every chunk of the rotation. + */ + suspend fun findNewRoot( + wraps: List, + baseRekeyKey: GroupKey, + recipientSigner: NostrSigner, + priorRoot: ByteArray, + rootEpoch: Long, + ): ReceivedRefounding? { + val newEpoch = rootEpoch + 1 + val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey() + val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() + for (wrap in wraps) { + val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue + val rumor = opened.rumor + if (rumor.kind != ConcordRekey.KIND) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue + + val blobs = ConcordRekey.decodeContent(rumor.content) + val rotatorXOnly = opened.author.hexToByteArray() + val newRoot = ConcordRekey.findNewKeyWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue + return ReceivedRefounding(newRoot, newEpoch, opened.author) + } + return null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt new file mode 100644 index 0000000000..fed5b15eb9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import kotlinx.serialization.builtins.ListSerializer +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Rekey distribution (CORD-06): non-ratcheted, asynchronous key rotation that + * removes members from a channel (or, in a Refounding, the whole community) while + * keeping the new key secret from those removed. + * + * The rotator publishes a kind-3303 rumor whose content is a JSON array of + * [RekeyBlob]s, one per remaining member. Each blob's `locator` is the recipient's + * pseudonym (public-input HKDF), and its `wrapped` field is the 72-byte + * [RekeyPayload] (base64 → NIP-44 under the rotator↔recipient pairwise key). A + * recipient computes their own locator, finds the matching blob, and decrypts the + * new key; a member with no matching blob across all chunks of a complete rotation + * has been removed. + * + * Pinned to the Concord v2 reference client for interop. + */ +object ConcordRekey { + const val TAG_SCOPE = "scope" + const val TAG_NEWEPOCH = "newepoch" + const val TAG_PREVEPOCH = "prevepoch" + const val TAG_PREVCOMMIT = "prevcommit" + const val TAG_CHUNK = "chunk" + + /** All-zero scope id marks a community_root refounding rather than a channel rekey. */ + val ROOT_SCOPE: ByteArray = ByteArray(32) + + /** + * Builds a rekey blob delivering [newKey] to one recipient. + * + * @param rotatorPrivKey the rotator's private key (their real identity) + * @param rotatorXOnly the rotator's x-only pubkey + * @param recipientXOnly the recipient's x-only pubkey + */ + @OptIn(ExperimentalEncodingApi::class) + fun blobFor( + rotatorPrivKey: ByteArray, + rotatorXOnly: ByteArray, + recipientXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + newKey: ByteArray, + ): RekeyBlob { + val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode()) + val convKey = Nip44.v2.getConversationKey(rotatorPrivKey, recipientXOnly) + val wrapped = Nip44.v2.encrypt(payloadB64, convKey).encodePayload() + return RekeyBlob(locator, wrapped) + } + + /** The kind-3303 rumor tags for a rekey chunk. */ + fun tags( + scopeId: ByteArray, + newEpoch: Long, + prevEpoch: Long, + prevCommit: HexKey, + chunkIndex: Int, + chunkTotal: Int, + ): Array> = + arrayOf( + arrayOf(TAG_SCOPE, scopeId.toHexKey()), + arrayOf(TAG_NEWEPOCH, newEpoch.toString()), + arrayOf(TAG_PREVEPOCH, prevEpoch.toString()), + arrayOf(TAG_PREVCOMMIT, prevCommit), + arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()), + ) + + /** Serializes a chunk's blobs into the kind-3303 rumor content. */ + fun encodeContent(blobs: List): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs) + + /** Parses a kind-3303 rumor's content back into its blobs, or empty on error. */ + fun decodeContent(content: String): List = + try { + ConcordJson.instance.decodeFromString(ListSerializer(RekeyBlob.serializer()), content) + } catch (_: Exception) { + emptyList() + } + + const val KIND: Int = 3303 + + /** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */ + const val MAX_BLOBS_PER_CHUNK = 120 + + /** + * Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw + * private key, so a NIP-46 bunker rotator can mint blobs without exposing its + * key (the wrap is a single `nip44Encrypt` to the recipient). The locator is + * public-input-only (CORD-06 §2) and needs no signing. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun blobForSigner( + rotatorSigner: NostrSigner, + recipientXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + newKey: ByteArray, + ): RekeyBlob { + val rotatorXOnly = rotatorSigner.pubKey.hexToByteArray() + val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode()) + val wrapped = rotatorSigner.nip44Encrypt(payloadB64, recipientXOnly.toHexKey()) + return RekeyBlob(locator, wrapped) + } + + /** + * Finds the recipient's rotated key like [findNewKey], but decrypts the blob via + * [recipientSigner] (bunker-compatible) rather than a raw private key. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun findNewKeyWithSigner( + blobs: List, + recipientSigner: NostrSigner, + rotatorXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + ): ByteArray? { + val recipientXOnly = recipientSigner.pubKey.hexToByteArray() + val myLocator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val blob = blobs.firstOrNull { it.locator == myLocator } ?: return null + return try { + val payload = RekeyPayload.decode(Base64.Default.decode(recipientSigner.nip44Decrypt(blob.wrapped, rotatorXOnly.toHexKey()))) ?: return null + if (!payload.scopeId.contentEquals(scopeId) || payload.epoch != newEpoch) return null + payload.newKey + } catch (_: Exception) { + null + } + } + + /** + * Finds the recipient's rotated key across the [blobs] of one or more chunks, + * or null if they were removed. Computes the recipient's locator, matches it, + * decrypts under the pairwise key, and verifies the payload's scope and epoch. + * + * @param recipientPrivKey the recipient's private key + * @param recipientXOnly the recipient's x-only pubkey + * @param rotatorXOnly the rotator's x-only pubkey + */ + @OptIn(ExperimentalEncodingApi::class) + fun findNewKey( + blobs: List, + recipientPrivKey: ByteArray, + recipientXOnly: ByteArray, + rotatorXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + ): ByteArray? { + val myLocator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val blob = blobs.firstOrNull { it.locator == myLocator } ?: return null + return try { + val convKey = Nip44.v2.getConversationKey(recipientPrivKey, rotatorXOnly) + val payload = RekeyPayload.decode(Base64.Default.decode(Nip44.v2.decrypt(blob.wrapped, convKey))) ?: return null + if (!payload.scopeId.contentEquals(scopeId) || payload.epoch != newEpoch) return null + payload.newKey + } catch (_: Exception) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt new file mode 100644 index 0000000000..e841183732 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import kotlinx.serialization.Serializable + +/** + * One recipient's entry in a rekey (CORD-06): a [locator] (the recipient's + * pseudonym, so only they know it's for them) and the [wrapped] new key (the + * 72-byte payload, base64'd then NIP-44-encrypted under the rotator↔recipient + * pairwise key). + */ +@Serializable +class RekeyBlob( + val locator: String, + val wrapped: String, +) + +/** + * The 72-byte rekey payload: `scope_id[32] ‖ epoch_be8 ‖ new_key[32]` + * (CORD-06 §2). Fixed-width so a recipient can verify the scope and epoch it + * decrypts to match what they expected before adopting [newKey]. + */ +class RekeyPayload( + val scopeId: ByteArray, + val epoch: Long, + val newKey: ByteArray, +) { + fun encode(): ByteArray { + require(scopeId.size == 32) { "scopeId must be 32 bytes" } + require(newKey.size == 32) { "newKey must be 32 bytes" } + val out = ByteArray(SIZE) + scopeId.copyInto(out, 0) + ConcordKeyDerivation.writeBe64(out, 32, epoch) + newKey.copyInto(out, 40) + return out + } + + companion object { + const val SIZE = 72 + + fun decode(bytes: ByteArray): RekeyPayload? { + if (bytes.size != SIZE) return null + var epoch = 0L + for (i in 0 until 8) epoch = (epoch shl 8) or (bytes[32 + i].toLong() and 0xFF) + return RekeyPayload(bytes.copyOfRange(0, 32), epoch, bytes.copyOfRange(40, 72)) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordBrokerToken.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordBrokerToken.kt new file mode 100644 index 0000000000..5c509a1b65 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordBrokerToken.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord07Voice + +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The blind-broker token request (CORD-07 §2). A member proves Channel membership + * to a stateless SFU broker by signing a NIP-98-style kind-27235 event with the + * Channel's derived **voice signer key** — whose public key is the SFU room name, + * so only members (who can derive it) can mint valid requests. The broker holds + * no Community secret. + * + * The request rides an `Authorization: Concord ` header against + * `GET /.well-known/concord/av/`. + */ +object ConcordBrokerToken { + const val KIND = 27235 // NIP-98 HTTP auth + const val AUTH_SCHEME = "Concord" + const val TAG_URL = "u" + const val TAG_METHOD = "method" + + /** The broker path for a voice room (the room = the voice signer's x-only pubkey hex). */ + fun wellKnownPath(voiceRoomHex: String): String = "/.well-known/concord/av/$voiceRoomHex" + + /** + * Builds the kind-27235 auth event for [url]/[method], signed by the channel's + * [voiceSigner] key (its public key is the voice room / SFU name). + */ + fun buildAuthEvent( + voiceSigner: GroupKey, + url: String, + createdAt: Long, + method: String = "GET", + ): Event { + val signer = NostrSignerSync(KeyPair(privKey = voiceSigner.secretKey)) + return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_URL, url), arrayOf(TAG_METHOD, method)), "") + } + + /** The `Authorization` header value carrying a base64 of the signed auth [event]. */ + @OptIn(ExperimentalEncodingApi::class) + fun authorizationHeader(event: Event): String = "$AUTH_SCHEME " + Base64.Default.encode(event.toJson().encodeToByteArray()) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt new file mode 100644 index 0000000000..9cb06f4925 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord07Voice + +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** A parsed voice presence: who is in the call, under which SFU [identity], and on which [broker]. */ +class VoicePresenceInfo( + val author: HexKey, + val channelId: HexKey?, + val epoch: Long?, + val joined: Boolean, + val identity: String?, + val broker: String?, + val createdAt: Long, +) + +/** + * Voice/video presence (CORD-07 §4): ephemeral kind-23313 rumors sealed on the + * Channel plane (like Chat Plane messages), announcing that a member is in the + * call under a broker-assigned SFU [VoicePresenceInfo.identity]. + * + * A participant renders as a verified member only when **exactly one author's + * fresh signed presence** claims an identity ([verifiedParticipants]); contested + * identities render unverified. Presence is heartbeated every + * [HEARTBEAT_MS] and considered absent after [STALE_MS]. + */ +object VoicePresence { + const val KIND = 23313 + const val CONTENT_JOINED = "joined" + const val CONTENT_LEFT = "left" + const val TAG_IDENTITY = "identity" + const val TAG_BROKER = "broker" + + const val HEARTBEAT_MS = 30_000L + const val STALE_MS = 90_000L + + /** A "joined" presence bound to the channel/epoch, carrying the SFU [identity] and optional [broker]. */ + fun joined( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + identity: String, + createdAt: Long, + broker: String? = null, + subMs: Int? = null, + ): Event { + val tags = ArrayList>() + tags.add(ChannelTag.assemble(channelId)) + tags.add(EpochTag.assemble(epoch)) + tags.add(arrayOf(TAG_IDENTITY, identity)) + if (broker != null) tags.add(arrayOf(TAG_BROKER, broker)) + if (subMs != null) tags.add(arrayOf("ms", subMs.toString())) + return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), CONTENT_JOINED) + } + + /** A "left" presence bound to the channel/epoch. */ + fun left( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + createdAt: Long, + ): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), CONTENT_LEFT) + + fun parse(rumor: Event): VoicePresenceInfo? { + if (rumor.kind != KIND) return null + return VoicePresenceInfo( + author = rumor.pubKey, + channelId = ChannelChat.channelOf(rumor), + epoch = ChannelChat.epochOf(rumor), + joined = rumor.content == CONTENT_JOINED, + identity = rumor.tags.firstTagValue(TAG_IDENTITY), + broker = rumor.tags.firstTagValue(TAG_BROKER), + createdAt = rumor.createdAt, + ) + } + + /** True if [presence] is within [STALE_MS] of [nowMs] (createdAt is unix seconds). */ + fun isFresh( + presence: VoicePresenceInfo, + nowMs: Long, + ): Boolean = nowMs - presence.createdAt * 1000 <= STALE_MS + + /** + * Maps each SFU identity to its single verified author across the given fresh + * [presences]. An identity claimed by zero or more-than-one author is omitted + * (contested identities render unverified). + */ + fun verifiedParticipants(presences: List): Map { + val claimants = HashMap>() + for (p in presences) { + if (!p.joined) continue + val id = p.identity ?: continue + claimants.getOrPut(id) { HashSet() }.add(p.author) + } + val out = HashMap() + for ((id, authors) in claimants) { + if (authors.size == 1) out[id] = authors.first() + } + return out + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt new file mode 100644 index 0000000000..b59827aadd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Concord key derivation (CORD-02 Appendix A, CORD-03, CORD-06, CORD-07). + * + * Everything a Concord community needs is derived deterministically from a small + * number of secrets so that "only holders of the secret can derive a plane's + * address, and only members can produce events at it." All of this is pinned to + * the Concord v2 reference client (Armada) for wire interoperability — see + * [ConcordLabels] for the frozen label strings. + * + * The core primitive is [groupKey]. Its info layout is + * `utf8(label) ‖ 0x00 ‖ id[32]? ‖ epoch_be8?` fed into `HKDF-SHA256(ikm=secret, + * salt=zeros(32), info, L=32)`, with a counter-byte retry ([deriveSecretKey]) on + * the astronomically rare chance the 32-byte output is not a valid secp256k1 + * scalar. + */ +object ConcordKeyDerivation { + private val hkdf = Hkdf() + + /** RFC 5869 "salt not provided" — HashLen (32) zero bytes. */ + private val ZERO_SALT = ByteArray(32) + + /** + * Builds the HKDF `info` for a plane/channel derivation: + * `utf8(label) ‖ 0x00 ‖ id[32]? ‖ epoch_be8?`. + * + * [id] is appended verbatim when present (32 bytes for community/channel ids, + * or `sha256(identity)` for voice-sender keys). [epoch] is appended as a + * big-endian unsigned 64-bit integer when present, and omitted otherwise. + */ + fun buildInfo( + label: String, + id: ByteArray? = null, + epoch: Long? = null, + ): ByteArray { + val labelBytes = label.encodeToByteArray() + val idLen = id?.size ?: 0 + val epochLen = if (epoch != null) 8 else 0 + val out = ByteArray(labelBytes.size + 1 + idLen + epochLen) + var pos = 0 + labelBytes.copyInto(out, pos) + pos += labelBytes.size + out[pos] = 0x00 + pos += 1 + if (id != null) { + id.copyInto(out, pos) + pos += id.size + } + if (epoch != null) { + writeBe64(out, pos, epoch) + } + return out + } + + /** `HKDF-SHA256(ikm=secret, salt=zeros(32), info, L=32)` — a raw 32-byte output. */ + fun hkdf32( + secret: ByteArray, + info: ByteArray, + ): ByteArray { + val prk = hkdf.extract(secret, ZERO_SALT) + return hkdf.expand(prk, info, 32) + } + + /** + * Derives a valid secp256k1 secret key from [secret] and [info]. + * + * Runs [hkdf32]; if the result is not a valid scalar (0 or ≥ curve order — + * probability ≈ 2⁻¹²⁸), appends an incrementing counter byte (0…255) to the + * info and retries, matching the reference client's deterministic fallback. + */ + fun deriveSecretKey( + secret: ByteArray, + info: ByteArray, + ): ByteArray { + val first = hkdf32(secret, info) + if (Secp256k1Instance.isPrivateKeyValid(first)) return first + + val extended = ByteArray(info.size + 1) + info.copyInto(extended) + var counter = 0 + while (counter <= 255) { + extended[info.size] = counter.toByte() + val candidate = hkdf32(secret, extended) + if (Secp256k1Instance.isPrivateKeyValid(candidate)) return candidate + counter++ + } + throw IllegalStateException("Unable to derive a valid secp256k1 scalar for the given secret/info") + } + + /** + * The core Concord derivation: turns a shared [secret] into a plane/channel + * [GroupKey] (secret key, x-only public address, and self-ECDH NIP-44 + * conversation key) at the given [id] and [epoch]. + */ + fun groupKey( + label: String, + secret: ByteArray, + id: ByteArray? = null, + epoch: Long? = null, + ): GroupKey { + val sk = deriveSecretKey(secret, buildInfo(label, id, epoch)) + val keyPair = KeyPair(privKey = sk) + val conversationKey = Nip44.v2.getConversationKey(sk, keyPair.pubKey) + return GroupKey(sk, keyPair.pubKey, conversationKey) + } + + /** + * The permanent, self-certifying community id (CORD-02): + * `sha256("concord/community" ‖ owner_xonly ‖ owner_salt)`. + * + * A plain SHA-256 commitment (not HKDF-shaped), so a bundle can never smuggle + * a false owner or a fake key for a real community. + */ + fun communityId( + ownerXOnly: ByteArray, + ownerSalt: ByteArray, + ): ByteArray { + val prefix = ConcordLabels.COMMUNITY.encodeToByteArray() + val preimage = ByteArray(prefix.size + ownerXOnly.size + ownerSalt.size) + prefix.copyInto(preimage, 0) + ownerXOnly.copyInto(preimage, prefix.size) + ownerSalt.copyInto(preimage, prefix.size + ownerXOnly.size) + return sha256(preimage) + } + + /** Fresh 32-byte owner salt, generated once at community creation (CORD-02). */ + fun newOwnerSalt(): ByteArray = RandomInstance.bytes(32) + + // ---- CORD-07 voice keys (all ride the Channel's epoch) -------------------- + + /** Voice signer keypair; its x-only public key is the SFU room name (CORD-07 §1). */ + fun voiceSignerKey( + channelSecret: ByteArray, + channelId: ByteArray, + epoch: Long, + ): GroupKey = groupKey(ConcordLabels.VOICE_SIGNER, channelSecret, channelId, epoch) + + /** 32-byte voice media root; per-sender frame keys derive from it (CORD-07 §1). */ + fun voiceMediaKey( + channelSecret: ByteArray, + channelId: ByteArray, + epoch: Long, + ): ByteArray = hkdf32(channelSecret, buildInfo(ConcordLabels.VOICE_MEDIA, channelId, epoch)) + + /** + * Per-sender voice frame key (CORD-07 §3): + * `hkdf32(voice_media_key, "concord/voice-sender" ‖ 0x00 ‖ sha256(utf8(identity)))`. + * There is no epoch field — the media key already rides the epoch. + */ + fun voiceSenderKey( + voiceMediaKey: ByteArray, + identity: String, + ): ByteArray = hkdf32(voiceMediaKey, buildInfo(ConcordLabels.VOICE_SENDER, sha256(identity.encodeToByteArray()))) + + // ---- Plane keys (CORD-02) ------------------------------------------------- + + /** The Control Plane address for a community at [epoch] (holders of the root only). */ + fun controlPlaneKey( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + ): GroupKey = groupKey(ConcordLabels.CONTROL, communityRoot, communityId, epoch) + + /** The Guestbook Plane address for a community at [epoch]. */ + fun guestbookPlaneKey( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + ): GroupKey = groupKey(ConcordLabels.GUESTBOOK, communityRoot, communityId, epoch) + + // ---- Control entity coordinates (CORD-04) --------------------------------- + // Keyless coordinates: the community id is the HKDF ikm; distinct labels and + // id bytes give each entity kind its own address. All raw hkdf32 (32 bytes). + + /** The Grant entity id for a member: `hkdf32(communityId, "concord/grant" ‖ 0x00 ‖ member)`. */ + fun grantCoordinate( + communityId: ByteArray, + memberXOnly: ByteArray, + ): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.GRANT, memberXOnly)) + + /** The community-wide Banlist entity id: `hkdf32(communityId, "concord/banlist" ‖ 0x00 ‖ ZERO32)`. */ + fun banlistCoordinate(communityId: ByteArray): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.BANLIST, ByteArray(32))) + + /** The invite-registry entity id for a creator: `hkdf32(communityId, "concord/invite-links" ‖ 0x00 ‖ creator)`. */ + fun inviteLinksCoordinate( + communityId: ByteArray, + creatorXOnly: ByteArray, + ): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.INVITE_LINKS, creatorXOnly)) + + // ---- CORD-05 invite bundle key -------------------------------------------- + + /** + * Derives the invite bundle decryption key from a link's 16-byte unlock + * [token] (CORD-05): `hkdf32(token, "concord/invite-key" ‖ 0x00 ‖ ZERO32)`. Per + * Appendix A.1 the `id` is *always present*, 32 bytes, all-zeroes for a label + * with no meaningful id (A.6 lists `concord/invite-key` with `id = 0…0`), so the + * 32 zero bytes must be fed into the HKDF `info` — omitting them yields a key that + * fails to open a reference-client (Armada) bundle. The token lives only in the URL + * fragment, so a server that sees the naddr can never open the bundle. + */ + fun inviteBundleKey(token: ByteArray): ByteArray = hkdf32(token, buildInfo(ConcordLabels.INVITE_KEY, ByteArray(32))) + + // ---- CORD-06 rekey addresses & commitment --------------------------------- + + /** + * The base-rotation rekey address for a Refounding (CORD-06 §2 Subscription): + * `group_key("concord/base-rekey-pseudonym", prior_community_root, community_id, + * new_epoch)`. The rotator publishes the kind-3303 blobs here and every current + * member precomputes it (from the root they already hold at the *next* epoch) to + * receive their new root in real time. Keyed by the **prior** root on purpose so + * the address stays computable by everyone who still holds it. + */ + fun baseRekeyAddress( + priorCommunityRoot: ByteArray, + communityId: ByteArray, + newEpoch: Long, + ): GroupKey = groupKey(ConcordLabels.BASE_REKEY_PSEUDONYM, priorCommunityRoot, communityId, newEpoch) + + /** + * The per-channel rekey address (CORD-06 §2): `group_key("concord/rekey-pseudonym", + * prior_community_root, channel_id, new_channel_epoch)`. Used when rotating a single + * Private Channel's key rather than the whole community. + */ + fun channelRekeyAddress( + priorCommunityRoot: ByteArray, + channelId: ByteArray, + newChannelEpoch: Long, + ): GroupKey = groupKey(ConcordLabels.REKEY_PSEUDONYM, priorCommunityRoot, channelId, newChannelEpoch) + + /** + * The epoch-key commitment (CORD-02 §A.5): `sha256("concord/epoch-key-commitment" + * ‖ prev_epoch_be8 ‖ prev_key[32])`. A rekey event carries this as `prevcommit`; + * a receiver recomputes it over the key it currently holds and requires equality + * before adopting the new key, proving the rotation extends its own chain. + */ + fun epochKeyCommitment( + prevEpoch: Long, + prevKey: ByteArray, + ): ByteArray { + val prefix = ConcordLabels.EPOCH_KEY_COMMITMENT.encodeToByteArray() + val preimage = ByteArray(prefix.size + 8 + prevKey.size) + prefix.copyInto(preimage, 0) + writeBe64(preimage, prefix.size, prevEpoch) + prevKey.copyInto(preimage, prefix.size + 8) + return sha256(preimage) + } + + // ---- CORD-06 rekey locator ------------------------------------------------ + + /** + * Recipient locator / pseudonym for a rekey blob (CORD-06 §2). Derived purely + * from public inputs (`rotator_xonly ‖ recipient_xonly` as IKM), so bunker + * accounts can locate their blob without touching raw keys. + */ + fun recipientLocator( + rotatorXOnly: ByteArray, + recipientXOnly: ByteArray, + scopeId: ByteArray, + epoch: Long, + ): ByteArray { + val ikm = ByteArray(rotatorXOnly.size + recipientXOnly.size) + rotatorXOnly.copyInto(ikm, 0) + recipientXOnly.copyInto(ikm, rotatorXOnly.size) + return hkdf32(ikm, buildInfo(ConcordLabels.RECIPIENT_PSEUDONYM, scopeId, epoch)) + } + + /** Writes [value] as a big-endian unsigned 64-bit integer into [out] at [offset]. */ + internal fun writeBe64( + out: ByteArray, + offset: Int, + value: Long, + ) { + for (i in 0 until 8) { + out[offset + i] = (value ushr (8 * (7 - i))).toByte() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt new file mode 100644 index 0000000000..0edb36ec9f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.crypto + +/** + * Frozen domain-separation labels used across the Concord protocol (CORD-01…07). + * + * These strings are part of the wire contract: every implementation must feed the + * exact same UTF-8 bytes into HKDF for members to derive matching plane keys. They + * are pinned to the Concord v2 reference client (Soapbox's Armada, `concord-v2`), + * which is the interoperability target. Do not rename or re-case them. + */ +object ConcordLabels { + /** Prefix for the SHA-256 community-id commitment (CORD-02). Not an HKDF label. */ + const val COMMUNITY = "concord/community" + + /** Per-Channel Chat Plane key (CORD-03). */ + const val CHANNEL = "concord/channel" + + /** Control Plane key (CORD-02). */ + const val CONTROL = "concord/control" + + /** Guestbook Plane key (CORD-02). */ + const val GUESTBOOK = "concord/guestbook" + + /** Grant coordinate derivation (CORD-04). */ + const val GRANT = "concord/grant" + + /** Banlist coordinate derivation (CORD-04). */ + const val BANLIST = "concord/banlist" + + /** Invite-link coordinate derivation (CORD-05). */ + const val INVITE_LINKS = "concord/invite-links" + + /** Invite bundle decryption key from the unlock token (CORD-05). */ + const val INVITE_KEY = "concord/invite-key" + + /** Dissolution tombstone coordinate (CORD-02). */ + const val DISSOLVED = "concord/dissolved" + + /** Voice signer keypair — public key is the SFU room name (CORD-07). */ + const val VOICE_SIGNER = "concord/voice-signer" + + /** Voice media root key (CORD-07). */ + const val VOICE_MEDIA = "concord/voice-media" + + /** Per-sender voice frame key (CORD-07). No epoch field in the info. */ + const val VOICE_SENDER = "concord/voice-sender" + + /** Rekey recipient pseudonym / locator (CORD-06). */ + const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym" + + /** Channel-scoped rekey pseudonym (CORD-06). */ + const val REKEY_PSEUDONYM = "concord/rekey-pseudonym" + + /** community_root-scoped rekey pseudonym for Refoundings (CORD-06). */ + const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym" + + /** Epoch-key commitment prefix for a rekey's `prevcommit` (CORD-02 §A.5). Not an HKDF label. */ + const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHash.kt new file mode 100644 index 0000000000..d7fb4793ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHash.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.crypto + +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Edition-hash chain for Control Plane editions (CORD-04 §1). + * + * Every authority edition (metadata, role, channel, grant, banlist, …) has an + * identity computed by [hash]. The next edition cites this value in its `ep` tag, + * forming an unforgeable chain: clients refuse downgrades and fold to the highest + * version with an intact chain. + * + * The preimage is fully domain-separated and every field is fixed-width or + * length-prefixed, so distinct inputs can never collide: + * + * ``` + * len64(label) ‖ label ‖ eid[32] ‖ ver_be64 ‖ hasPrev(1) ‖ prev[32] ‖ len64(content) ‖ content + * ``` + * + * where `label` is the frozen [DOMAIN] string, all `len*`/`ver` fields are + * big-endian unsigned 64-bit integers, `hasPrev` is `0x01`/`0x00`, `prev` is the + * previous edition hash (32 zero bytes for the first edition), and `content` is + * the **exact wire bytes** of the rumor content — never re-serialized. + */ +object EditionHash { + /** Frozen domain-separation label, pinned to the Concord v2 reference client. */ + const val DOMAIN = "vector-community/v1/edition" + + private val ZERO_32 = ByteArray(32) + + fun hash( + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: ByteArray, + ): ByteArray { + val label = DOMAIN.encodeToByteArray() + val prev = prevHash ?: ZERO_32 + require(entityId.size == 32) { "entityId must be 32 bytes, was ${entityId.size}" } + require(prev.size == 32) { "prevHash must be 32 bytes, was ${prev.size}" } + + // 8 + label + 32 + 8 + 1 + 32 + 8 + content + val preimage = ByteArray(8 + label.size + 32 + 8 + 1 + 32 + 8 + content.size) + var pos = 0 + pos = writeBe64(preimage, pos, label.size.toLong()) + label.copyInto(preimage, pos) + pos += label.size + entityId.copyInto(preimage, pos) + pos += 32 + pos = writeBe64(preimage, pos, version) + preimage[pos] = if (prevHash != null) 0x01 else 0x00 + pos += 1 + prev.copyInto(preimage, pos) + pos += 32 + pos = writeBe64(preimage, pos, content.size.toLong()) + content.copyInto(preimage, pos) + + return sha256(preimage) + } + + /** Convenience overload that hashes the UTF-8 bytes of a [content] string. */ + fun hash( + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: String, + ): ByteArray = hash(entityId, version, prevHash, content.encodeToByteArray()) + + private fun writeBe64( + out: ByteArray, + offset: Int, + value: Long, + ): Int { + ConcordKeyDerivation.writeBe64(out, offset, value) + return offset + 8 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/GroupKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/GroupKey.kt new file mode 100644 index 0000000000..42748a661b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/GroupKey.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * The address + keys of a Concord message plane (Control, Chat, Guestbook, …) or + * a derived channel, all produced by [ConcordKeyDerivation.groupKey]. + * + * A plane is a stream on Nostr keyed by a single shared key: + * - [secretKey] signs the stream wraps (kind 1059) at this address and derives + * the [conversationKey]. + * - [publicKey] is the 32-byte x-only pubkey that is the stream's address — + * members `REQ` for kind-1059 events authored by it. + * - [conversationKey] is the NIP-44 self-ECDH conversation key used to encrypt + * the wrap content (self-ECDH of [secretKey] against its own [publicKey]). + * + * Rotating the epoch (or the underlying secret) rotates [publicKey], keeping a + * plane's traffic unlinkable across epochs (CORD-02 §Epochs). + */ +class GroupKey( + val secretKey: ByteArray, + val publicKey: ByteArray, + val conversationKey: ByteArray, +) { + /** Lower-case hex of the x-only [publicKey] — the stream address as it appears on the wire. */ + val publicKeyHex: String get() = publicKey.toHexKey() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt new file mode 100644 index 0000000000..acd8c2a007 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.envelope + +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.crypto.verifyId +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The Concord stream envelope (CORD-01): a three-layer wrap → seal → rumor that + * carries every plane's traffic on Nostr. + * + * This is a deliberate **inversion** of NIP-59: the outer wrap is signed by the + * shared *stream key* (a plane's [GroupKey]) and carries an ephemeral `["p", …]` + * tag, rather than being signed by a random key and addressed to a fixed + * recipient. Because the true author's rumor is only ever visible after + * decrypting under the stream conversation key, a relay can never retain or + * display the plaintext as a public event. + * + * ``` + * kind 1059/21059 wrap signed by stream key, content = NIP-44(seal, streamConvKey) + * └─ kind 20013/20014 seal signed by the real author + * └─ rumor unsigned author event (kind 9, 3308, 3306, …) + * ``` + * + * Two seal flavors (CORD-01 §Encryption): + * - **Plaintext seal (20014)** — `content` is the rumor JSON verbatim. Required + * by the Control Plane so an author's signature survives re-encryption across + * epochs (the exact bytes must be preserved). + * - **Encrypted seal (20013)** — `content` is the rumor JSON NIP-44-encrypted + * under the same stream conversation key, hiding it twice over. Used by every + * plane that never crosses an epoch or re-seeds with fresh attestations. + * + * All of this is pinned to the Concord v2 reference client for wire interop. + */ +object ConcordStreamEnvelope { + const val KIND_WRAP = 1059 + const val KIND_WRAP_EPHEMERAL = 21059 + const val KIND_SEAL_ENCRYPTED = 20013 + const val KIND_SEAL_PLAINTEXT = 20014 + + /** + * Seals [rumor] for the [stream] plane, signed by [authorSigner] (the real + * author's key). [encrypted] selects a 20013 encrypted seal; otherwise a + * 20014 plaintext seal. The seal inherits the rumor's `created_at`. + */ + suspend fun seal( + rumor: Event, + stream: GroupKey, + authorSigner: NostrSigner, + encrypted: Boolean, + ): Event { + val content = + if (encrypted) { + Nip44.v2.encrypt(rumor.toJson(), stream.conversationKey).encodePayload() + } else { + rumor.toJson() + } + val kind = if (encrypted) KIND_SEAL_ENCRYPTED else KIND_SEAL_PLAINTEXT + return authorSigner.sign(rumor.createdAt, kind, EMPTY_TAGS, content) + } + + /** + * Wraps an already-built [seal] into a stream event at the [stream] plane's + * address, signed by the stream key and encrypted under its conversation key. + * Adds a fresh ephemeral `["p", …]` tag. Use [KIND_WRAP_EPHEMERAL] via + * [ephemeral] for transient traffic (typing, voice presence). + */ + fun wrapSeal( + seal: Event, + stream: GroupKey, + ephemeral: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): Event { + val streamSigner = NostrSignerSync(KeyPair(privKey = stream.secretKey)) + val content = Nip44.v2.encrypt(seal.toJson(), stream.conversationKey).encodePayload() + val ephemeralP = KeyPair().pubKey.toHexKey() + val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP + return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content) + } + + /** Convenience: [seal] then [wrapSeal] in one call. */ + suspend fun wrap( + rumor: Event, + stream: GroupKey, + authorSigner: NostrSigner, + encrypted: Boolean, + ephemeral: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt) + + /** + * Opens a stream [wrap] for the [stream] plane and returns the verified author + * rumor, or throws if any layer fails to validate: + * 1. `wrap.pubkey` must equal the stream address, and the wrap must be signed + * by the stream key. + * 2. `wrap.content` decrypts under the stream conversation key into a seal + * whose own signature must verify against `seal.pubkey`. + * 3. For a 20013 seal the rumor decrypts under the same conversation key; a + * 20014 seal carries it verbatim. + * 4. The rumor's author must equal the seal's author (no impersonation) and + * its `id` must be the correct NIP-01 event hash. + */ + fun open( + wrap: Event, + stream: GroupKey, + ): OpenedStreamEvent { + require(wrap.kind == KIND_WRAP || wrap.kind == KIND_WRAP_EPHEMERAL) { + "Not a Concord stream wrap: kind ${wrap.kind}" + } + require(wrap.pubKey == stream.publicKeyHex) { + "Wrap author ${wrap.pubKey} is not the stream address ${stream.publicKeyHex}" + } + require(wrap.verify()) { "Wrap signature/id is invalid" } + + val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, stream.conversationKey)) + require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) { + "Not a Concord seal: kind ${seal.kind}" + } + require(seal.verify()) { "Seal signature/id is invalid" } + + val rumorJson = + if (seal.kind == KIND_SEAL_ENCRYPTED) { + Nip44.v2.decrypt(seal.content, stream.conversationKey) + } else { + seal.content + } + + val rumor = Event.fromJson(rumorJson) + require(rumor.pubKey == seal.pubKey) { + "Rumor author ${rumor.pubKey} does not match seal author ${seal.pubKey}" + } + require(rumor.verifyId()) { "Rumor id ${rumor.id} is not its NIP-01 hash" } + + return OpenedStreamEvent(rumor, seal.kind, seal.pubKey, seal) + } + + /** Like [open] but returns null instead of throwing on any validation failure. */ + fun openOrNull( + wrap: Event, + stream: GroupKey, + ): OpenedStreamEvent? = + try { + open(wrap, stream) + } catch (_: Exception) { + null + } + + private val EMPTY_TAGS = emptyArray>() +} + +/** + * The verified result of opening a stream wrap: the author [rumor], the + * [sealKind] it arrived under (20013/20014), the true [author] pubkey (equal to + * `rumor.pubKey`, surfaced for convenience), and the verified inner [seal] event + * itself. The [seal] carries the original author's signature, so a Refounding can + * re-wrap a plaintext control seal under a fresh root without re-signing it + * (CORD-06 §3 compaction). + */ +class OpenedStreamEvent( + val rumor: Event, + val sealKind: Int, + val author: String, + val seal: Event, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt index ee4df00fe2..f13652d067 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -28,11 +28,14 @@ import kotlin.concurrent.Volatile class RelayAuthStatus { // Keeps track of auth responses to update the relay with all filters - // after the authentication happen - private val authResponseWatcher: LruCache = LruCache(10) + // after the authentication happen. + // Sized generously: one connection may authenticate as many identities at once — the + // user plus every Concord plane stream key hosted on that relay (control + channels) — + // and if older entries roll off, OK-tracking / hasFinishedAllAuths() accounting degrades. + private val authResponseWatcher: LruCache = LruCache(200) // Avoids sending multiple replies for each auth. - private val uniqueAuthChallengesSent: LruCache = LruCache(10) + private val uniqueAuthChallengesSent: LruCache = LruCache(200) // Latest epoch-second at which a tracked AUTH event received a successful OK. // Read by RelayAuthSnapshot consumers for staleness checks (e.g. proactive @@ -40,6 +43,22 @@ class RelayAuthStatus { @Volatile private var lastAuthSuccessAt: Long? = null + // The most recent challenge the relay sent on this connection. NIP-42: the challenge + // "is valid for the duration of the connection or until another challenge is sent", + // and a client "must have a stored challenge associated with that relay so it can act + // upon that in response to the auth-required CLOSED message". We keep it so a REQ that + // is refused with `auth-required:` AFTER the initial AUTH (e.g. a Concord channel-plane + // REQ mounted once the control plane folds and reveals new stream keys) can be + // re-authenticated with the folded-in keys without waiting for the relay to re-challenge. + @Volatile + private var lastChallenge: String? = null + + fun rememberChallenge(challenge: String) { + lastChallenge = challenge + } + + fun lastChallenge(): String? = lastChallenge + enum class AuthEventReceiptStatus { AUTHENTICATING, AUTHENTICATED, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index e91ed4ac79..a6e3493714 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd @@ -61,8 +63,13 @@ class RelayAuthenticator( * Signs the auth template for every currently-logged-in account and returns the signed events. * The [relay] parameter allows callers to check per-relay auth policy before signing. * Returns an empty list to skip authentication for this relay. + * + * [interactive] is true for a fresh relay AUTH challenge (the signer MAY surface a user + * prompt for an undecided relay) and false for an automatic re-auth triggered by an + * `auth-required:` CLOSED (the signer must NOT prompt — it may only re-attach identities + * that are already approved, such as ledger-ALLOW accounts and derived stream keys). */ - val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate) -> List, + val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate, interactive: Boolean) -> List, ) : IAuthStatus { // Connection callbacks fire on the per-relay OkHttp dispatcher thread, so // this state is mutated concurrently — LargeCache wraps a platform-tuned @@ -101,8 +108,9 @@ class RelayAuthenticator( msg: Message, ) { when (msg) { - is AuthMessage -> authenticate(relay, msg) + is AuthMessage -> authenticate(relay, msg.challenge, interactive = true) is OkMessage -> checkAuthResults(relay, msg) + is ClosedMessage -> reauthenticateIfAuthRequired(relay, msg) } } @@ -119,8 +127,11 @@ class RelayAuthenticator( private fun authenticate( relay: IRelayClient, - msg: AuthMessage, + challenge: String, + interactive: Boolean, ) { + // Store the challenge so a later `auth-required:` CLOSED can reuse it (NIP-42). + authStatus.get(relay.url)?.rememberChallenge(challenge) scope.launch { // Relay auth is automatic and not user-initiated. Signing can fail in // benign, expected ways — e.g. an external NIP-55 signer prompt that the @@ -129,8 +140,8 @@ class RelayAuthenticator( // a CoroutineExceptionHandler (viewModelScope, rememberCoroutineScope, …), // so an uncaught throwable here crashes the whole app. Swallow + log them. try { - val ev = RelayAuthEvent.build(relay.url, msg.challenge) - signWithAllLoggedInUsers(relay.url, ev).forEach { authEvent -> + val ev = RelayAuthEvent.build(relay.url, challenge) + signWithAllLoggedInUsers(relay.url, ev, interactive).forEach { authEvent -> // only send replies to new challenges to avoid infinite loop: if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) { relay.sendIfConnected(AuthCmd(authEvent)) @@ -147,6 +158,31 @@ class RelayAuthenticator( } } + /** + * NIP-42: a relay sends the challenge only in an `AUTH` message, never in a `CLOSED`. + * When a REQ is refused with an `auth-required:` CLOSED (e.g. a Concord channel-plane + * REQ mounted after the control plane folded and revealed new stream keys), the relay + * does NOT re-issue a challenge — the client is expected to reuse the one already stored + * for the connection. Re-run the sign/send pass with that challenge: [saveAuthSubmission] + * dedups by (pubkey, challenge), so only identities we haven't AUTHed on this challenge + * yet (the folded-in keys) are actually sent — a no-op once they all are, so no loop. + */ + private fun reauthenticateIfAuthRequired( + relay: IRelayClient, + msg: ClosedMessage, + ) { + if (MachineReadablePrefix.parse(msg.message) != MachineReadablePrefix.AUTH_REQUIRED) return + val status = authStatus.get(relay.url) ?: return + // Coalesce the burst: a relay refuses EVERY currently-open sub with its own `auth-required` + // CLOSED, so a single missing identity yields many CLOSEDs at once. Re-signing on each would + // re-hit an external (NIP-55) signer for every ledger-ALLOW account. Skip while an AUTH is + // still in flight — the OK of the one we already sent runs [checkAuthResults] → syncFilters, + // which re-drives the refused REQ; if it's still refused, that fresh CLOSED re-auths then. + if (!status.hasFinishedAllAuths()) return + val challenge = status.lastChallenge() ?: return + authenticate(relay, challenge, interactive = false) + } + private fun checkAuthResults( relay: IRelayClient, msg: OkMessage, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index a1de1467cb..4957e42c81 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.utils +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -603,6 +606,9 @@ class EventFactory { RootSiteEvent.KIND -> RootSiteEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) + ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) + ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) + ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) SimpleGroupListEvent.KIND -> SimpleGroupListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt new file mode 100644 index 0000000000..0c234713ca --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConcordCommunityFactoryTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun createsSelfCertifyingCommunityWithGenesisEditions() = + runTest { + val community = + ConcordCommunityFactory.create( + ownerSigner = owner, + name = "Nostrichs", + createdAt = 1_700_000_000L, + description = "a cozy place", + relays = listOf("wss://relay.example"), + ) + + // community_id is the self-certifying commitment to owner + salt + assertContentEquals( + ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), community.ownerSalt), + community.communityId, + ) + + // Two genesis wraps, both authored by the Control Plane address. + assertEquals(2, community.genesisWraps.size) + community.genesisWraps.forEach { + assertEquals(ConcordStreamEnvelope.KIND_WRAP, it.kind) + assertEquals(community.controlPlane.publicKeyHex, it.pubKey) + } + + // Genesis wraps open with plaintext (20014) seals, authored by the owner. + val opened = community.genesisWraps.map { ConcordStreamEnvelope.open(it, community.controlPlane) } + opened.forEach { + assertEquals(ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT, it.sealKind) + assertEquals(owner.pubKey, it.author) + } + } + + @Test + fun genesisFoldsToLiveCommunityStateWithGeneralChannelAndOwnerAuthority() = + runTest { + val community = + ConcordCommunityFactory.create(owner, name = "Gamers", createdAt = 1L, relays = listOf("wss://r.example")) + + val state = ConcordCommunityState.fold(community.genesisEditions, community.ownerPubKey) + + assertEquals("Gamers", state.metadata?.name) + assertEquals(listOf("wss://r.example"), state.metadata?.relays) + + val general = state.channels[community.generalChannelIdHex] + assertNotNull(general) + assertEquals(ConcordCommunityFactory.GENERAL_CHANNEL_NAME, general.definition.name) + assertFalse(general.definition.private) + + // The owner is supreme from genesis; no channels are private, none deleted. + assertTrue(state.authority.isOwner(owner.pubKey)) + assertEquals(0L, state.authority.rank(owner.pubKey)) + assertFalse(state.dissolved) + } + + @Test + fun differentCommunitiesFromSameOwnerHaveDistinctIds() = + runTest { + val a = ConcordCommunityFactory.create(owner, "A", 1L) + val b = ConcordCommunityFactory.create(owner, "B", 1L) + // distinct salts ⇒ distinct ids (one owner, many communities) + assertFalse(a.communityIdHex == b.communityIdHex) + assertFalse(a.communityRoot.toHexKey() == b.communityRoot.toHexKey()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEventTest.kt new file mode 100644 index 0000000000..4270f1a469 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEventTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ConcordCommunityListEventTest { + private val signer = NostrSignerInternal(KeyPair()) + + private val entry = + ConcordCommunityListEntry( + id = "11".repeat(32), + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "bb".repeat(32), + rootEpoch = 0, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + @Test + fun eventFactoryReturnsTypedClassAndDecrypts() = + runTest { + val event = ConcordCommunityListEvent.create(signer, listOf(entry), createdAt = 1L) + assertEquals(ConcordCommunityListEvent.KIND, event.kind) + assertTrue(!event.content.contains("Nostrichs")) // encrypted on the wire + + // A round-trip through JSON parsing resolves to the typed class via EventFactory. + val reparsed = Event.fromJson(event.toJson()) + assertIs(reparsed) + + val entries = reparsed.decrypt(signer) + assertEquals(1, entries.size) + assertEquals("Nostrichs", entries[0].name) + assertEquals(listOf("wss://relay.example"), entries[0].relays) + } + + @Test + fun replaceableAddressIsKindPubkeyEmpty() { + val addr = ConcordCommunityListEvent.createAddress(signer.pubKey) + assertEquals(ConcordCommunityListEvent.KIND, addr.kind) + assertEquals(signer.pubKey, addr.pubKeyHex) + assertEquals("", addr.dTag) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt new file mode 100644 index 0000000000..9a9dea784d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordCommunityListTest { + private val signer = NostrSignerInternal(KeyPair()) + private val other = NostrSignerInternal(KeyPair()) + + private fun entry( + id: String, + name: String, + epoch: Long = 0, + ) = ConcordCommunityListEntry( + id = id, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "bb".repeat(32), + rootEpoch = epoch, + relays = listOf("wss://relay.example"), + name = name, + ) + + @Test + fun selfEncryptedListRoundTrips() = + runTest { + val entries = listOf(entry("11".repeat(32), "Gamers"), entry("22".repeat(32), "Nostrichs")) + val event = ConcordCommunityList.build(signer, entries, createdAt = 1_700_000_000L) + + assertEquals(ConcordCommunityListEvent.KIND, event.kind) + assertFalse(event.content.contains("Gamers")) // encrypted on the wire + + val parsed = ConcordCommunityList.parse(event, signer) + assertEquals(2, parsed.size) + assertEquals("Gamers", parsed[0].name) + assertEquals(listOf("wss://relay.example"), parsed[0].relays) + } + + @Test + fun onlyTheOwnerCanDecrypt() = + runTest { + val event = ConcordCommunityList.build(signer, listOf(entry("11".repeat(32), "Secret")), createdAt = 1L) + assertTrue(ConcordCommunityList.parse(event, other).isEmpty()) // wrong key ⇒ nothing + } + + @Test + fun decodesArmadaWireDocument() { + // A document as Soapbox Armada writes it (communityList.ts): {entries:[{community_id, + // seed, current, added_at}], tombstones:[]} with snake_case JoinMaterial. + val json = + """ + { + "entries": [ + { + "community_id": "${"11".repeat(32)}", + "seed": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"bb".repeat(32)}", + "root_epoch": 0, + "channels": [], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox" + }, + "current": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 2, + "channels": [ + { "id": "${"ee".repeat(32)}", "key": "${"dd".repeat(32)}", "epoch": 2, "name": "secret" } + ], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox", + "held_roots": [ { "epoch": 1, "key": "${"bb".repeat(32)}" } ] + }, + "added_at": 1700000000000 + } + ], + "tombstones": [] + } + """.trimIndent() + + val entries = ConcordCommunityList.decode(json) + assertEquals(1, entries.size) + val e = entries[0] + assertEquals("11".repeat(32), e.id) + assertEquals("Soapbox", e.name) + assertEquals("cc".repeat(32), e.root) // hydrated from `current`, not `seed` + assertEquals(2L, e.rootEpoch) + assertEquals(1700000000000L, e.addedAt) + assertEquals(listOf("wss://relay.ditto.pub"), e.relays) + assertEquals(1, e.privateChannels.size) + assertEquals("ee".repeat(32), e.privateChannels[0].channelId) + assertEquals("secret", e.privateChannels[0].name) + assertEquals(1, e.heldRoots.size) + assertEquals(1L, e.heldRoots[0].epoch) + } + + @Test + fun tombstoneAfterAddDropsEntry() { + val jm = """{"community_id":"${"11".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"aa".repeat(32)}","community_root":"${"bb".repeat(32)}","root_epoch":0,"channels":[],"relays":[],"name":"Gone"}""" + val json = + """{"entries":[{"community_id":"${"11".repeat(32)}","seed":$jm,"current":$jm,"added_at":100}],"tombstones":[{"community_id":"${"11".repeat(32)}","removed_at":200}]}""" + assertTrue(ConcordCommunityList.decode(json).isEmpty()) // removed after add ⇒ not live + } + + @Test + fun mergeKeepsFreshestEpochPerCommunity() { + val a = listOf(entry("11".repeat(32), "Old", epoch = 1)) + val b = listOf(entry("11".repeat(32), "New", epoch = 3), entry("22".repeat(32), "Other", epoch = 0)) + val merged = ConcordCommunityList.merge(a, b) + assertEquals(2, merged.size) + assertEquals("New", merged.first { it.id == "11".repeat(32) }.name) // higher epoch wins + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt new file mode 100644 index 0000000000..501e44697e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordCommunityStateTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val adminRole = "11".repeat(32) + + private fun edition( + kind: ControlEntityKind, + eid: String, + content: String, + author: String = owner, + ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0) + + @Test + fun foldsMetadataChannelsRolesAndAuthority() { + val editions = + listOf( + edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"My Server","description":"hi"}"""), + edition(ControlEntityKind.CHANNEL, "c1".repeat(32), """{"name":"general","private":false}"""), + edition(ControlEntityKind.CHANNEL, "c2".repeat(32), """{"name":"voice-lounge","private":false,"voice":true}"""), + edition(ControlEntityKind.CHANNEL, "c3".repeat(32), """{"name":"old","deleted":true}"""), + edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), + edition(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$alice","role_ids":["$adminRole"]}"""), + ) + + val state = ConcordCommunityState.fold(editions, owner) + + assertEquals("My Server", state.metadata?.name) + assertEquals("hi", state.metadata?.description) + + // deleted channel excluded; general + voice channel kept + assertEquals(2, state.channels.size) + assertEquals("general", state.channels["c1".repeat(32)]?.definition?.name) + assertTrue(state.channels["c2".repeat(32)]?.definition?.voice == true) + assertNull(state.channels["c3".repeat(32)]) + + assertNotNull(state.roles[adminRole]) + assertEquals(1L, state.authority.rank(alice)) + assertFalse(state.dissolved) + } + + @Test + fun dissolutionTombstoneMarksCommunityDissolved() { + val editions = + listOf( + edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"Doomed"}"""), + edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""), + ) + val state = ConcordCommunityState.fold(editions, owner) + assertTrue(state.dissolved) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt new file mode 100644 index 0000000000..30b30480ac --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GuestbookTest { + private val member = KeyPair().pubKey.toHexKey() + private val creator = KeyPair().pubKey.toHexKey() + private val target = KeyPair().pubKey.toHexKey() + + @Test + fun joinWithInviteAttributionRoundTrips() { + val rumor = Guestbook.join(member, createdAt = 1_700_000_000L, subMs = 128, inviteCreator = creator, inviteLabel = "Reddit") + assertEquals(Guestbook.KIND_JOIN_LEAVE, rumor.kind) + assertEquals("join", rumor.content) + + val entry = Guestbook.parse(rumor) + assertEquals(GuestbookAction.JOIN, entry?.action) + assertEquals(member, entry?.member) + assertEquals(creator, entry?.inviteCreator) + assertEquals("Reddit", entry?.inviteLabel) + } + + @Test + fun leaveParses() { + val entry = Guestbook.parse(Guestbook.leave(member, createdAt = 1L)) + assertEquals(GuestbookAction.LEAVE, entry?.action) + assertNull(entry?.inviteCreator) + } + + @Test + fun kickTargetsAMember() { + val rumor = Guestbook.kick(actorPubKey = creator, target = target, createdAt = 1L) + assertEquals(Guestbook.KIND_KICK, rumor.kind) + assertEquals(target, Guestbook.kickTarget(rumor)) + } + + @Test + fun parseIgnoresNonGuestbookRumors() { + assertNull(Guestbook.parse(Guestbook.kick(creator, target, 1L))) // kick is not a join/leave + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt new file mode 100644 index 0000000000..c13c53164c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ImagePointerTest { + /** + * The community icon/banner are CORD-02 §6 [ImagePointer] *objects* on the wire (Concord v2 + * reference client), not URL strings. Typing them as `String` — the old bug — makes the whole + * MetadataEntity fail to decode, silently dropping the community name too. This pins the object + * shape decoding correctly, name included. + */ + @Test + fun decodesArmadaShapeMetadataWithEncryptedIconObject() { + val json = + """ + { + "name": "NosFabrica", + "description": "a community", + "icon": { "url": "https://media.example/icon.enc", "key": "${"1a".repeat(32)}", "nonce": "${"2b".repeat(16)}", "hash": "${"3c".repeat(32)}" }, + "banner": { "url": "https://media.example/banner.enc", "key": "${"4d".repeat(32)}", "nonce": "${"5e".repeat(16)}", "hash": "${"6f".repeat(32)}" }, + "relays": ["wss://relay.example/"] + } + """.trimIndent() + + val md = ConcordJson.decodeOrNull(json) + assertNotNull(md, "an object-shaped icon must decode, not fail the whole entity") + assertEquals("NosFabrica", md.name) + assertEquals("https://media.example/icon.enc", md.icon?.url) + assertEquals("2b".repeat(16), md.icon?.nonce) + assertEquals("https://media.example/banner.enc", md.banner?.url) + assertTrue(md.icon!!.isResolvable()) + } + + /** A metadata with no images still decodes (both pointers null). */ + @Test + fun decodesMetadataWithoutImages() { + val md = ConcordJson.decodeOrNull("""{"name":"NoPics"}""") + assertNotNull(md) + assertEquals("NoPics", md.name) + assertNull(md.icon) + assertNull(md.banner) + } + + /** decryptOrNull round-trips AES-256-GCM with the pointer's key/nonce and verifies the plaintext hash. */ + @Test + fun decryptRoundTripsAndVerifiesHash() { + val plaintext = "the real PNG bytes".encodeToByteArray() + val key = ByteArray(32) { it.toByte() } + val nonce = ByteArray(16) { (it + 7).toByte() } + val ciphertext = AESGCM(key, nonce).encrypt(plaintext) + + val pointer = + ImagePointer( + url = "https://media.example/blob", + key = key.toHexKey(), + nonce = nonce.toHexKey(), + hash = sha256(plaintext).toHexKey(), + ) + + assertEquals(plaintext.toHexKey(), pointer.decryptOrNull(ciphertext)?.toHexKey()) + } + + /** A swapped blob (wrong plaintext hash) fails closed — decryptOrNull returns null, never garbage. */ + @Test + fun tamperedHashFailsClosed() { + val plaintext = "original".encodeToByteArray() + val key = ByteArray(32) { it.toByte() } + val nonce = ByteArray(16) { it.toByte() } + val ciphertext = AESGCM(key, nonce).encrypt(plaintext) + + val wrongHashPointer = + ImagePointer( + url = "https://media.example/blob", + key = key.toHexKey(), + nonce = nonce.toHexKey(), + hash = "00".repeat(32), // not the plaintext's hash + ) + + assertNull(wrongHashPointer.decryptOrNull(ciphertext)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt new file mode 100644 index 0000000000..0c371e1749 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Full CORD-01+03 vertical slice: two members holding the same community_root + * independently derive a public channel key, and one reads the other's message + * off the shared plane — no key distribution required. + */ +class ChannelChatEndToEndTest { + private val communityRoot = ByteArray(32) { 0x5A } + private val channelId = ByteArray(32) { 0x42 } + private val channelIdHex = channelId.toHexKey() + private val rootEpoch = 0L + + @Test + fun twoMembersShareAPublicChannelWithoutKeyDistribution() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + + // Alice derives the public channel plane and sends a message. + val aliceChannel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + val rumor = ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "gm #general", createdAt = 1_700_000_000L) + val wrap = ConcordStreamEnvelope.wrap(rumor, aliceChannel, alice, encrypted = true) + + // Bob, holding the same community_root, derives the identical plane and reads it. + val bobChannel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + assertEquals(aliceChannel.publicKeyHex, bobChannel.publicKeyHex) + + val opened = ConcordStreamEnvelope.open(wrap, bobChannel) + assertEquals("gm #general", opened.rumor.content) + assertEquals(alice.pubKey, opened.author) + assertTrue(ChannelChat.isBoundTo(opened.rumor, channelIdHex, rootEpoch)) + } + + @Test + fun bindingRejectsCrossChannelAndCrossEpochReplay() { + val rumor = + ChannelChat.message( + authorPubKey = KeyPair().pubKey.toHexKey(), + channelId = channelIdHex, + epoch = 0L, + text = "hi", + createdAt = 1L, + ) + assertTrue(ChannelChat.isBoundTo(rumor, channelIdHex, 0L)) + assertFalse(ChannelChat.isBoundTo(rumor, channelIdHex, 1L)) // wrong epoch + assertFalse(ChannelChat.isBoundTo(rumor, "00".repeat(32), 0L)) // wrong channel + assertEquals(channelIdHex, ChannelChat.channelOf(rumor)) + assertEquals(0L, ChannelChat.epochOf(rumor)) + } + + @Test + fun inlineReplyIsAKind9QuoteWhileThreadReplyIsAKind1111Comment() { + val author = KeyPair().pubKey.toHexKey() + val parent = + ChannelChat.message(authorPubKey = author, channelId = channelIdHex, epoch = 0L, text = "root", createdAt = 1L) + + // Inline quote-reply: a normal kind-9 message, quoting the parent via `q`, still channel-bound. + val inline = ChannelChat.inlineReply(author, channelIdHex, 0L, "inline", parent.id, parent.pubKey, 2L) + assertEquals(9, inline.kind) + assertEquals(parent.id, inline.tags.first { it[0] == "q" }[1]) + assertTrue(ChannelChat.isBoundTo(inline, channelIdHex, 0L)) + + // Thread reply: a kind-1111 NIP-22 comment, uppercase `E` root + lowercase `e` parent, channel-bound. + val thread = ChannelChat.reply(author, channelIdHex, 0L, "thread", parent, 3L) + assertEquals(1111, thread.kind) + assertEquals(parent.id, thread.tags.first { it[0] == "E" }[1]) + assertEquals(parent.id, thread.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(thread, channelIdHex, 0L)) + } + + @Test + fun typingHeartbeatIsAnEphemeralWrapReadableByAnotherMember() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + + val rumor = ChannelChat.typing(alice.pubKey, channelIdHex, rootEpoch, createdAt = 1_700_000_000L) + val wrap = ConcordStreamEnvelope.wrap(rumor, channel, alice, encrypted = true, ephemeral = true) + + // The wrap is the ephemeral kind so relays broadcast but never store it. + assertEquals(ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL, wrap.kind) + + val opened = ConcordStreamEnvelope.open(wrap, channel) + assertTrue(ChannelChat.isTyping(opened.rumor)) + assertEquals(ChannelChat.KIND_TYPING, opened.rumor.kind) + assertEquals(alice.pubKey, opened.author) + assertTrue(ChannelChat.isBoundTo(opened.rumor, channelIdHex, rootEpoch)) + assertFalse(ChannelChat.isTyping(ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "hi", 1L))) + } + + @Test + fun encryptedImageMessageMatchesArmadaWireFormatAndRoundTrips() { + val author = KeyPair().pubKey.toHexKey() + val cipher = AESGCM(ByteArray(32) { 0x11 }, ByteArray(16) { 0x22 }) + val url = "https://blossom.example/ciphertext.bin" + val ox = "aa".repeat(32) + + val imeta = + ChannelChat.encryptedImageImeta( + url = url, + mimeType = "image/jpeg", + dim = "800x600", + blurhash = "LKO2", + cipher = cipher, + originalHash = ox, + ) + val msg = ChannelChat.imageMessage(author, channelIdHex, 0L, "look", listOf(imeta), createdAt = 5L) + + // Still a channel-bound kind-9; the ciphertext url is appended to content (Armada assembly). + assertEquals(9, msg.kind) + assertTrue(ChannelChat.isBoundTo(msg, channelIdHex, 0L)) + assertEquals("look\n$url", msg.content) + + // The imeta tag carries exactly Armada's fields: aes-gcm + hex key/nonce + ox, and NO `x`. + val imetaTag = msg.tags.first { it[0] == "imeta" } + assertTrue(imetaTag.contains("url $url")) + assertTrue(imetaTag.contains("m image/jpeg")) + assertTrue(imetaTag.contains("dim 800x600")) + assertTrue(imetaTag.contains("encryption-algorithm aes-gcm")) + assertTrue(imetaTag.contains("decryption-key ${ByteArray(32) { 0x11 }.toHexKey()}")) + assertTrue(imetaTag.contains("decryption-nonce ${ByteArray(16) { 0x22 }.toHexKey()}")) + assertTrue(imetaTag.contains("ox $ox")) + assertTrue(imetaTag.none { it.startsWith("x ") }) + + // Receiver parses the attachment back with the same key/nonce for decryption. + val parsed = ChannelChat.encryptedImagesOf(msg) + assertEquals(1, parsed.size) + val att = parsed.first() + assertEquals(url, att.url) + assertEquals("image/jpeg", att.mimeType) + assertEquals("aes-gcm", att.algo) + assertEquals(ox, att.originalHash) + assertTrue(att.key.contentEquals(ByteArray(32) { 0x11 })) + assertTrue(att.nonce.contentEquals(ByteArray(16) { 0x22 })) + + // A plaintext message has no encrypted attachments. + assertTrue(ChannelChat.encryptedImagesOf(ChannelChat.message(author, channelIdHex, 0L, "hi", 1L)).isEmpty()) + } + + @Test + fun nonMembersCannotDeriveThePlane() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + val wrap = + ConcordStreamEnvelope.wrap( + ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "secret", 1L), + channel, + alice, + encrypted = true, + ) + + // A different community_root derives a different plane key ⇒ cannot open. + val outsiderPlane = ConcordChannelKeys.publicChannel(ByteArray(32) { 0x01 }, channelId, rootEpoch) + assertNull(ConcordStreamEnvelope.openOrNull(wrap, outsiderPlane)) + } + + @Test + fun epochRotationRotatesTheChannelAddress() { + val e0 = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + val e1 = ConcordChannelKeys.publicChannel(communityRoot, channelId, 1) + assertFalse(e0.publicKeyHex == e1.publicKeyHex) + + // A private channel with its own key is distinct from the public one at the same id. + val priv = ConcordChannelKeys.privateChannel(ByteArray(32) { 0x77 }, channelId, 0) + assertFalse(priv.publicKeyHex == e0.publicKeyHex) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt new file mode 100644 index 0000000000..c7c7b203dd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AuthorityResolverTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + private val carol = "c3".repeat(32) + private val dave = "d4".repeat(32) + + private val adminRole = "11".repeat(32) + private val modRole = "22".repeat(32) + + // admin: position 1, KICK|BAN|MANAGE_ROLES = 8|16|1 = 25 + private val adminJson = """{"name":"Admin","position":1,"permissions":"25"}""" + + // mod: position 5, KICK only = 8 (no MANAGE_ROLES) + private val modJson = """{"name":"Mod","position":5,"permissions":"8"}""" + + private fun role( + roleId: String, + json: String, + ) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0) + + private fun grant( + grantId: String, + member: String, + roleIds: List, + granter: String, + ) = ControlEdition( + ControlEntityKind.GRANT, + grantId.hexToByteArray(), + 0, + null, + null, + """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""", + granter, + "grant-$grantId", + 0, + ) + + private fun banlist(vararg banned: String) = banlistBy(owner, "ban", *banned) + + private fun banlistBy( + author: String, + rumorId: String, + vararg banned: String, + ) = ControlEdition( + ControlEntityKind.BANLIST, + "44".repeat(32).hexToByteArray(), + 0, + null, + null, + "[${banned.joinToString(",") { "\"$it\"" }}]", + author, + rumorId, + 0, + ) + + @Test + fun ranksPermissionsAndActionAuthorityAreOwnerRooted() { + val heads = + listOf( + role(adminRole, adminJson), + role(modRole, modJson), + // alice's grant appears BEFORE the owner's grant to prove fixpoint order-independence + grant("ba".repeat(32), bob, listOf(modRole), granter = alice), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + ) + val r = AuthorityResolver.resolve(heads, owner) + + assertTrue(r.isOwner(owner)) + assertEquals(0L, r.rank(owner)) + assertEquals(1L, r.rank(alice)) + assertEquals(5L, r.rank(bob)) + assertNull(r.rank(carol)) // no grant ⇒ no authority + + assertTrue(r.effectivePermissions(alice).has(BAN)) + assertFalse(r.effectivePermissions(bob).has(BAN)) + assertTrue(r.effectivePermissions(bob).has(KICK)) + + // Higher rank can act on lower; equal cannot act on equal; nobody on owner. + assertTrue(r.canActOn(alice, bob, BAN)) + assertFalse(r.canActOn(bob, alice, KICK)) // lower cannot act on higher + assertFalse(r.canActOn(alice, alice, KICK)) // equal-on-equal + assertFalse(r.canActOn(alice, owner, BAN)) // owner unremovable + assertTrue(r.canActOn(owner, alice, BAN)) // owner supreme + } + + @Test + fun grantsFromUnauthorizedSignersAreIgnored() { + val heads = + listOf( + role(adminRole, adminJson), + // carol has no authority, so her grant to dave is dropped + grant("cd".repeat(32), dave, listOf(adminRole), granter = carol), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertNull(r.rank(dave)) + assertEquals(ConcordPermissions.NONE.bits, r.effectivePermissions(dave).bits) + } + + @Test + fun granterMustHoldManageRolesAndOutrankAssignedRole() { + val heads = + listOf( + role(adminRole, adminJson), + role(modRole, modJson), + grant("ab".repeat(32), alice, listOf(modRole), granter = owner), // alice is a mod (no MANAGE_ROLES) + grant("ae".repeat(32), dave, listOf(adminRole), granter = alice), // mod cannot grant admin + ) + val r = AuthorityResolver.resolve(heads, owner) + assertEquals(5L, r.rank(alice)) + assertNull(r.rank(dave)) // rejected: alice lacks MANAGE_ROLES and doesn't outrank admin + } + + @Test + fun bannedMembersVanishFromAuthority() { + val heads = + listOf( + role(adminRole, adminJson), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + banlist(alice), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertTrue(r.isBanned(alice)) + // A banned actor can take no action even though the role bit is present. + assertFalse(r.hasPermission(alice, BAN)) + assertFalse(r.canActOn(alice, bob, BAN)) + } + + @Test + fun concurrentBansHealIntoAUnionAndAreNeverDropped() { + // Two authorized moderators ban different abusers at the same banlist version — a + // fork of the single banlist doc. Folding to one chain tip would silently drop the + // loser's ban and let that abuser back in; the union keeps both (M1 / CORD-06 + // down-only healing). + val heads = + listOf( + role(adminRole, adminJson), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), // alice gains BAN + banlistBy(owner, "ban-owner", bob), // owner bans bob + banlistBy(alice, "ban-alice", carol), // alice concurrently bans carol + ) + val r = AuthorityResolver.resolve(heads, owner) + assertTrue(r.isBanned(bob)) + assertTrue(r.isBanned(carol)) + } + + @Test + fun banlistEditionsFromUnauthorizedSignersAreIgnored() { + // carol holds no BAN permission, so her ban of dave must not take effect. + val heads = + listOf( + role(adminRole, adminJson), + banlistBy(carol, "ban-carol", dave), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertFalse(r.isBanned(dave)) + } + + @Test + fun deletedRolesAndPositionZeroAreDropped() { + val heads = + listOf( + role(adminRole, """{"name":"Admin","position":1,"permissions":"25","deleted":true}"""), + role(modRole, """{"name":"Peer","position":0,"permissions":"25"}"""), // illegal position 0 + grant("ab".repeat(32), alice, listOf(adminRole, modRole), granter = owner), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertNull(r.rank(alice)) // both assigned roles are invalid + } + + /** + * The reference client (Armada) writes a role's `scope` as an object + * (`{"kind":"server"}`), NOT a bare string. Typing the field as `String` made the whole + * RoleEntity fail to decode, dropping the role and every grant that depended on it — the + * community then had no resolvable admins, so authority-gated metadata/channels vanished. + */ + @Test + fun objectScopedRoleDecodesAndItsGrantResolves() { + val heads = + listOf( + role(adminRole, """{"name":"Admin","position":1,"permissions":"25","scope":{"kind":"server"},"color":0}"""), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertEquals(1L, r.rank(alice)) + assertTrue(r.effectivePermissions(alice).has(BAN)) + } + + /** + * The owner grants alice Admin (v0); an UNAUTHORIZED key mints a higher-version grant on the + * same coordinate stripping her roles. The structural head is the rogue v1, but an edition + * whose signer isn't authorized is dropped (CORD-04 §1) — so the fold must NOT let the rogue + * supersede the owner's grant. alice keeps Admin. (This was the live Soapbox failure.) + */ + @Test + fun rogueHigherVersionGrantCannotSupersedeALegitGrant() { + val grantId = "ab".repeat(32) + val ownerGrant = grant(grantId, alice, listOf(adminRole), granter = owner) // v0, prev null + val rogueV1 = + ControlEdition( + ControlEntityKind.GRANT, + grantId.hexToByteArray(), + 1, + ownerGrant.hash, // chains onto the owner's grant, so it wins the STRUCTURAL fold + null, + """{"member":"$alice","role_ids":[]}""", + carol, // an unauthorized signer + "grant-$grantId-rogue", + 1, + ) + val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner) + assertEquals(1L, r.rank(alice)) // rogue v1 dropped; the owner's v0 grant stands + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt new file mode 100644 index 0000000000..dbe9b16a18 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.MANAGE_ROLES +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.MENTION_EVERYONE +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordPermissionsTest { + @Test + fun bitPositionsAreFrozen() { + assertEquals(0, MANAGE_ROLES) + assertEquals(3, KICK) + assertEquals(4, BAN) + assertEquals(9, MENTION_EVERYONE) + } + + @Test + fun hasChecksIndividualBits() { + val p = ConcordPermissions.of(KICK, BAN) + assertTrue(p.has(KICK)) + assertTrue(p.has(BAN)) + assertFalse(p.has(MANAGE_ROLES)) + } + + @Test + fun unionIsBitwiseOr() { + val a = ConcordPermissions.of(KICK) + val b = ConcordPermissions.of(BAN) + val u = a union b + assertTrue(u.has(KICK)) + assertTrue(u.has(BAN)) + // effective permissions are the union of a member's roles' bits + assertEquals(ConcordPermissions.of(KICK, BAN).bits, u.bits) + } + + @Test + fun wireEncodingIsDecimalString() { + // KICK(3) | BAN(4) = 0b11000 = 24 + assertEquals("24", ConcordPermissions.of(KICK, BAN).toWire()) + assertEquals(ConcordPermissions.of(KICK, BAN).bits, ConcordPermissions.fromWire("24").bits) + } + + @Test + fun highBitsSurviveDecimalRoundTripWithoutFloatingPointLoss() { + // Bit 63 set — a value that would be corrupted if parsed as a JSON double. + val hi = ConcordPermissions(1uL shl 63) + val wire = hi.toWire() + assertEquals("9223372036854775808", wire) + assertEquals(hi.bits, ConcordPermissions.fromWire(wire).bits) + } + + @Test + fun blankParsesToNoneAndGarbageIsRejected() { + assertEquals(ConcordPermissions.NONE.bits, ConcordPermissions.fromWire("").bits) + assertNull(ConcordPermissions.fromWireOrNull("not-a-number")) + assertNull(ConcordPermissions.fromWireOrNull("-1")) + } + + @Test + fun entityKindWireMappingMatchesReference() { + assertEquals(ControlEntityKind.METADATA, ControlEntityKind.of("0")) + assertEquals(ControlEntityKind.ROLE, ControlEntityKind.of("1")) + assertEquals(ControlEntityKind.CHANNEL, ControlEntityKind.of("2")) + assertEquals(ControlEntityKind.GRANT, ControlEntityKind.of("3")) + assertEquals(ControlEntityKind.BANLIST, ControlEntityKind.of("4")) + assertEquals(ControlEntityKind.INVITE_LIVE, ControlEntityKind.of("6")) + assertEquals(ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.of("8")) + assertEquals(ControlEntityKind.INVITE_REVOKED, ControlEntityKind.of("9")) + assertEquals(ControlEntityKind.DISSOLVED, ControlEntityKind.of("10")) + assertNull(ControlEntityKind.of("7")) // retired/unknown + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt new file mode 100644 index 0000000000..16ca3ddc93 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.crypto.EditionHash +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ControlEditionTest { + private val author = KeyPair().pubKey.toHexKey() + private val eid = ByteArray(32) { 0xAB.toByte() } + + private fun edition( + version: Long, + prevHash: ByteArray?, + content: String, + rumorId: String, + ) = ControlEdition( + entityKind = ControlEntityKind.CHANNEL, + entityId = eid, + version = version, + prevHash = prevHash, + authorityCitation = null, + content = content, + author = author, + rumorId = rumorId, + createdAt = 1_700_000_000L + version, + ) + + // ---- fromRumor ------------------------------------------------------------ + + @Test + fun fromRumorParsesTagsAndComputesHash() { + val prev = ByteArray(32) { 0x01 } + val grantId = ByteArray(32) { 0x02 } + val grantHash = ByteArray(32) { 0x03 } + val content = """{"member":"aa","role_ids":["bb"]}""" + val tags = + arrayOf( + arrayOf("vsk", "3"), + arrayOf("eid", eid.toHexKey()), + arrayOf("ev", "4"), + arrayOf("ep", prev.toHexKey()), + arrayOf("vac", grantId.toHexKey(), "2", grantHash.toHexKey()), + ) + val rumor = RumorAssembler.assembleRumor(author, 1_700_000_000L, ControlEditionEvent.KIND, tags, content) + + val ed = ControlEdition.fromRumor(rumor) + assertNotNull(ed) + assertEquals(ControlEntityKind.GRANT, ed.entityKind) + assertContentEquals(eid, ed.entityId) + assertEquals(4, ed.version) + assertContentEquals(prev, ed.prevHash) + assertEquals(author, ed.author) + assertEquals(rumor.id, ed.rumorId) + assertContentEquals(EditionHash.hash(eid, 4, prev, content), ed.hash) + assertNotNull(ed.authorityCitation) + assertContentEquals(grantId, ed.authorityCitation.grantId) + assertEquals(2, ed.authorityCitation.grantVersion) + } + + @Test + fun fromRumorRejectsMalformed() { + // wrong kind + assertNull(ControlEdition.fromRumor(RumorAssembler.assembleRumor(author, 1L, 9, arrayOf(arrayOf("vsk", "0")), "{}"))) + // missing eid + assertNull( + ControlEdition.fromRumor( + RumorAssembler.assembleRumor(author, 1L, ControlEditionEvent.KIND, arrayOf(arrayOf("vsk", "0"), arrayOf("ev", "0")), "{}"), + ), + ) + // unknown vsk (bit 7 retired) + assertNull( + ControlEdition.fromRumor( + RumorAssembler.assembleRumor( + author, + 1L, + ControlEditionEvent.KIND, + arrayOf(arrayOf("vsk", "7"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")), + "{}", + ), + ), + ) + } + + @Test + fun genesisHasNullPrevWhenEpAbsent() { + val tags = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")) + val ed = ControlEdition.fromRumor(RumorAssembler.assembleRumor(author, 1L, ControlEditionEvent.KIND, tags, """{"name":"general"}""")) + assertNotNull(ed) + assertNull(ed.prevHash) + } + + // ---- fold ----------------------------------------------------------------- + + @Test + fun foldWalksIntactChainToHead() { + val v0 = edition(0, null, """{"name":"general"}""", "id0") + val v1 = edition(1, v0.hash, """{"name":"lounge"}""", "id1") + val v2 = edition(2, v1.hash, """{"name":"lobby"}""", "id2") + // order shuffled to prove fold is order-independent + val head = EditionFold.foldEntity(listOf(v2, v0, v1)) + assertEquals(v2.rumorId, head?.rumorId) + } + + @Test + fun foldStopsAtBreakAndRefusesDowngrade() { + val v0 = edition(0, null, """{"name":"general"}""", "id0") + // v2 present but v1 missing ⇒ head cannot advance past v0 + val v2 = edition(2, ByteArray(32) { 0x09 }, """{"name":"lobby"}""", "id2") + assertEquals(v0.rumorId, EditionFold.foldEntity(listOf(v0, v2))?.rumorId) + + // v1 with a prev that does not chain from v0 is ignored + val badV1 = edition(1, ByteArray(32) { 0x07 }, """{"name":"x"}""", "id1") + assertEquals(v0.rumorId, EditionFold.foldEntity(listOf(v0, badV1))?.rumorId) + } + + @Test + fun foldTieBreaksOnLowerRumorId() { + val a = edition(0, null, """{"name":"a"}""", "aaa") + val b = edition(0, null, """{"name":"b"}""", "bbb") + assertEquals("aaa", EditionFold.foldEntity(listOf(b, a))?.rumorId) + } + + /** + * A lone edition with a dangling `prev` and no genesis is the compacted head of a Refounded + * community (CORD-06 §3): a fresh joiner never holds the prior epoch it chains onto, so the + * head is accepted as the baseline rather than dropped (CORD-04 §1). Dropping it was the bug + * that hid a refounded community's icon, name, and edited channels. See [EditionFoldTest]. + */ + @Test + fun foldWithoutGenesisAcceptsCompactedHead() { + val v1 = edition(1, ByteArray(32) { 0x05 }, """{"name":"x"}""", "id1") + assertEquals("id1", EditionFold.foldEntity(listOf(v1))?.rumorId) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt new file mode 100644 index 0000000000..60ba6d58f3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EditionFoldTest { + private val author = KeyPair().pubKey.toHexKey() + private val eid = ByteArray(32) { 0xAB.toByte() } + + private fun edition( + version: Long, + prevHash: ByteArray?, + content: String, + rumorId: String = "id-v$version", + ) = ControlEdition( + entityKind = ControlEntityKind.CHANNEL, + entityId = eid, + version = version, + prevHash = prevHash, + authorityCitation = null, + content = content, + author = author, + rumorId = rumorId, + createdAt = 1_700_000_000L + version, + ) + + /** A normal chain (v0 genesis → v1 → v2) folds to the highest intact version. */ + @Test + fun foldsIntactChainToHead() { + val v0 = edition(0, null, "genesis") + val v1 = edition(1, v0.hash, "one") + val v2 = edition(2, v1.hash, "two") + + val head = EditionFold.foldEntity(listOf(v2, v0, v1)) + assertEquals(2, head?.version) + assertEquals("two", head?.content) + } + + /** + * The Refounding case (CORD-06 §3): a fresh joiner holds only the compacted head, whose + * `prev` cites the prior epoch it never fetched. With no genesis present, the head is + * accepted as the baseline rather than dropped — the bug that hid a refounded community's + * icon, name, and edited channels. + */ + @Test + fun acceptsDanglingCompactedHeadWhenNoGenesis() { + val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head") + + val head = EditionFold.foldEntity(listOf(danglingHead)) + assertEquals(5, head?.version) + assertEquals("compacted-head", head?.content) + } + + /** A dangling head plus post-refounding edits chains forward from the accepted baseline. */ + @Test + fun advancesFromDanglingHeadAsNewEditionsArrive() { + val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head") + val v6 = edition(6, danglingHead.hash, "post-refound edit") + + val head = EditionFold.foldEntity(listOf(v6, danglingHead)) + assertEquals(6, head?.version) + assertEquals("post-refound edit", head?.content) + } + + /** A genuine mid-chain gap still fails closed at the intact prefix — no silent jump past the hole. */ + @Test + fun stopsAtGapWhenGenesisPresent() { + val v0 = edition(0, null, "genesis") + // v1 is missing; v2 cites a hash we don't hold, so it can't chain onto v0. + val v2 = edition(2, ByteArray(32) { 0x77.toByte() }, "orphan") + + val head = EditionFold.foldEntity(listOf(v2, v0)) + assertEquals(0, head?.version) + assertEquals("genesis", head?.content) + } + + /** No editions at all → no head. */ + @Test + fun emptyFoldsToNull() { + assertNull(EditionFold.foldEntity(emptyList())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt new file mode 100644 index 0000000000..396ffbad1b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ConcordDirectInviteTest { + private val sender = NostrSignerInternal(KeyPair()) + private val recipient = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private val invite = + CommunityInvite( + communityId = "11".repeat(32), + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + communityRoot = "bb".repeat(32), + name = "Nostrichs", + ) + + @Test + fun directInviteRoundTripsToTheRecipient() = + runTest { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1_700_000_000L) + + // Wrap is a giftwrap tagged for the recipient and indexable by k=3313. + assertEquals(recipient.pubKey, wrap.tags.first { it[0] == "p" }[1]) + assertEquals("3313", wrap.tags.first { it[0] == "k" }[1]) + + val parsed = ConcordDirectInvite.parse(wrap, recipient) + assertNotNull(parsed) + assertEquals("Nostrichs", parsed.name) + assertEquals("11".repeat(32), parsed.communityId) + } + + @Test + fun strangersCannotOpenIt() = + runTest { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1L) + assertNull(ConcordDirectInvite.parse(wrap, stranger)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt new file mode 100644 index 0000000000..3d365679c8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The full public-invite path: create a community → mint an invite link → a + * stranger redeems the link, reconstructs the root, and reads the community's + * genesis Control Plane. This is the create-and-invite flow the app drives. + */ +class ConcordInviteJoinFlowTest { + private val owner = NostrSignerInternal(KeyPair()) + + private suspend fun inviteFor(community: com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity) = + CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + @Test + fun createMintRedeemAndRead() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // The joiner only has the URL. Extract the token from the private fragment. + val parsedUrl = ConcordInviteLink.parseUrl(minted.url) + assertNotNull(parsedUrl) + assertEquals(minted.linkSignerPubKey, parsedUrl.linkSignerPubKey) + + // Decrypt the fetched bundle with that token and verify self-certification. + val invite = ConcordInviteBundle.parse(minted.bundleEvent, parsedUrl.fragment.token) + assertNotNull(invite) + assertTrue(ConcordInviteBundle.validate(invite)) + assertEquals(community.communityIdHex, invite.communityId) + + // Reconstruct the root, derive the Control Plane, and read the genesis. + val controlPlane = + ConcordKeyDerivation.controlPlaneKey( + invite.communityRoot.hexToByteArray(), + invite.communityId.hexToByteArray(), + invite.rootEpoch, + ) + val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) } + val state = ConcordCommunityState.fold(editions, invite.owner) + assertEquals("Nostrichs", state.metadata?.name) + assertTrue(state.channels.isNotEmpty()) // #general is visible to the new member + } + + @Test + fun wrongTokenCannotOpenTheBundle() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Secret", createdAt = 1L) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L) + assertNull(ConcordInviteBundle.parse(minted.bundleEvent, ByteArray(16) { 0x01 })) // random token fails + } + + @Test + fun validateRejectsForgedOwner() { + // owner + salt that do not reproduce the claimed community_id + val forged = + CommunityInvite( + communityId = "00".repeat(32), + owner = KeyPair().pubKey.toHexKey(), + ownerSalt = "aa".repeat(32), + communityRoot = "bb".repeat(32), + ) + assertFalse(ConcordInviteBundle.validate(forged)) + } + + @Test + fun expiryBlocksJoiningButNotPreview() { + val invite = CommunityInvite("id", "o", "s", "r", expiresAt = 1_000L) + assertTrue(ConcordInviteBundle.isExpired(invite, nowMs = 2_000L)) + assertFalse(ConcordInviteBundle.isExpired(invite, nowMs = 500L)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt new file mode 100644 index 0000000000..b293e3596e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConcordInviteLinkTest { + private val token = ByteArray(16) { it.toByte() } + private val signer = KeyPair().pubKey.toHexKey() + + @Test + fun stockFragmentRoundTrips() { + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays = null)) + assertContentEquals(token, frag.token) + assertTrue(frag.usedStockRelays) + assertEquals(InviteRelayDictionary.STOCK, frag.relays) + + // passing the exact stock set also collapses to the stock flag + val fromStockList = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, InviteRelayDictionary.STOCK)) + assertTrue(fromStockList.usedStockRelays) + } + + @Test + fun dictionaryRelaysRoundTrip() { + val relays = listOf("wss://relay.ditto.pub", "wss://jskitty.com/nostr") // ids 3 and 1 + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) + assertFalse(frag.usedStockRelays) + assertEquals(relays, frag.relays) + assertContentEquals(token, frag.token) + } + + @Test + fun literalHostAndFullUrlRelaysRoundTrip() { + val relays = listOf("wss://myrelay.example/nostr", "ws://plain.example") + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) + assertEquals(relays, frag.relays) + } + + @Test + @OptIn(ExperimentalEncodingApi::class) + fun rejectsWrongVersion() { + // craft a version-3 fragment: [3, 0x01, token...] + val bytes = byteArrayOf(3, 0x01) + token + val legacy = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + assertFailsWith { ConcordInviteLink.decodeFragment(legacy) } + } + + @Test + fun fullUrlRoundTripsThroughNaddr() { + val url = ConcordInviteLink.buildUrl("https://vector.chat", signer, token) + assertTrue(url.startsWith("https://vector.chat/invite/naddr")) + + val parsed = ConcordInviteLink.parseUrl(url) + assertNotNull(parsed) + assertEquals(signer, parsed.linkSignerPubKey) + assertEquals(ConcordInviteBundleEvent.KIND, parsed.kind) + assertContentEquals(token, parsed.fragment.token) + } + + @Test + fun inviteBundleKeyIsDeterministicAndTokenBound() { + val k = ConcordKeyDerivation.inviteBundleKey(token) + assertEquals(32, k.size) + assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token)) + assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt new file mode 100644 index 0000000000..fa919e9b14 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordRefoundingTest { + private val owner = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) // retained + private val bob = NostrSignerInternal(KeyPair()) // retained + private val carol = NostrSignerInternal(KeyPair()) // removed + + private val newRoot = ByteArray(32) { 0x5A } + private val now = 1_700_000_000L + + @Test + fun retainedMembersGetNewRootRemovedDoesNot() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val communityId = community.communityId + val priorRoot = community.communityRoot + val priorControl = community.controlPlane + + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = communityId, + priorRoot = priorRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = priorControl, + recipientsXOnly = listOf(alice.pubKey, bob.pubKey), + createdAt = now, + ) + + assertEquals(community.rootEpoch + 1, build.newEpoch) + assertContentEquals(newRoot, build.newRoot) + + val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, build.newEpoch) + + // Alice and Bob find the new root; Carol (no blob) does not. + val aliceRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, priorRoot, community.rootEpoch) + val bobRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, bob, priorRoot, community.rootEpoch) + val carolRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, priorRoot, community.rootEpoch) + + assertNotNull(aliceRoot) + assertContentEquals(newRoot, aliceRoot.newRoot) + assertEquals(owner.pubKey, aliceRoot.rotator) + assertNotNull(bobRoot) + assertContentEquals(newRoot, bobRoot.newRoot) + assertNull(carolRoot) // removed member receives no blob + } + + @Test + fun compactedControlPlaneFoldsIdenticallyUnderNewRoot() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now, description = "A place") + val communityId = community.communityId + + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = community.controlPlane, + recipientsXOnly = listOf(alice.pubKey), + createdAt = now, + ) + + val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch) + + // Re-open the compacted wraps under the NEW control key and fold: same authority + metadata. + val editions = + build.controlWraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { + com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition + .fromRumor(it.rumor) + } + } + val folded = ConcordCommunityState.fold(editions, owner.pubKey) + + assertEquals("Test", folded.metadata?.name) + assertTrue(folded.authority.isOwner(owner.pubKey)) + // #general survives compaction (its head channel edition is re-sealed). + assertTrue(folded.channels.isNotEmpty()) + + // The re-sealed editions still verify as owner-signed (signature preserved across re-encryption). + build.controlWraps.forEach { wrap -> + val opened = ConcordStreamEnvelope.openOrNull(wrap, newControl) + assertNotNull(opened) + assertEquals(owner.pubKey, opened.author) + } + } + + @Test + fun freshJoinerSeesEntitiesEditedAfterGenesisThenRefounded() = + runTest { + // A real, long-lived community edits its metadata (adds an icon) and renames + // #general AFTER genesis, THEN gets refounded. Those edits produce version-1 + // editions whose `ep` chains onto the genesis edition. Compaction keeps only + // each entity's head — so the re-wrapped heads still carry a `prev` pointing at + // the (now absent) prior-epoch edition. A fresh joiner fetching only the + // compacted heads must still see them (CORD-04 §1 "Folding across a Refounding", + // CORD-06 §3): the signature + current-authority check is the whole test. + val community = ConcordCommunityFactory.create(owner, "NosFabrica", now) + val communityId = community.communityId + val control = community.controlPlane + + val genesisMeta = community.genesisEditions.first { it.entityKind == ControlEntityKind.METADATA } + val genesisChannel = community.genesisEditions.first { it.entityKind == ControlEntityKind.CHANNEL } + + val icon = ImagePointer(url = "https://media/icon.enc", key = "1a".repeat(32), nonce = "2b".repeat(16), hash = "3c".repeat(32)) + + // v1 metadata: add the icon, chained onto genesis. + val metaV1Json = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "NosFabrica", icon = icon)) + val metaV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.METADATA, communityId, 1, genesisMeta.hash, metaV1Json, now + 1) + val metaV1Wrap = ConcordStreamEnvelope.wrap(metaV1Rumor, control, owner, encrypted = false, createdAt = now + 1) + + // v1 channel: rename #general, chained onto genesis. + val chanV1Json = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), ChannelEntity(name = "lobby", private = false)) + val chanV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.CHANNEL, community.generalChannelId, 1, genesisChannel.hash, chanV1Json, now + 1) + val chanV1Wrap = ConcordStreamEnvelope.wrap(chanV1Rumor, control, owner, encrypted = false, createdAt = now + 1) + + val priorWraps = community.genesisWraps + metaV1Wrap + chanV1Wrap + + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = priorWraps, + priorControlKey = control, + recipientsXOnly = listOf(alice.pubKey), + createdAt = now, + ) + + val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch) + val editions = + build.controlWraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { ControlEdition.fromRumor(it.rumor) } + } + val folded = ConcordCommunityState.fold(editions, owner.pubKey) + + // A fresh joiner MUST see the compacted heads — name, icon, and the renamed channel. + assertEquals("NosFabrica", folded.metadata?.name, "fresh joiner lost the community name after refounding") + assertEquals(icon, folded.metadata?.icon, "fresh joiner lost the community icon after refounding") + assertEquals( + "lobby", + folded.channels.values + .firstOrNull() + ?.definition + ?.name, + "fresh joiner lost the (edited) channel after refounding", + ) + } + + @Test + fun wrongPriorRootFailsContinuity() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = community.communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = community.controlPlane, + recipientsXOnly = listOf(alice.pubKey), + createdAt = now, + ) + val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) + + // Alice claims a different prior root: prevcommit mismatch ⇒ rotation rejected. + val wrongRoot = ByteArray(32) { 0x11 } + assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, wrongRoot, community.rootEpoch)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt new file mode 100644 index 0000000000..c0e251dc0a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ConcordRekeyTest { + private val rotator = KeyPair() + private val alice = KeyPair() + private val bob = KeyPair() + private val carol = KeyPair() // removed member + + private val scope = ByteArray(32) { 0x42 } + private val newEpoch = 1L + private val newKey = ByteArray(32) { 0x7E } + + private fun blobFor(recipient: KeyPair) = ConcordRekey.blobFor(rotator.privKey!!, rotator.pubKey, recipient.pubKey, scope, newEpoch, newKey) + + private fun find( + recipient: KeyPair, + blobs: List, + epoch: Long = newEpoch, + ) = ConcordRekey.findNewKey(blobs, recipient.privKey!!, recipient.pubKey, rotator.pubKey, scope, epoch) + + @Test + fun payloadEncodesAndDecodes() { + val decoded = RekeyPayload.decode(RekeyPayload(scope, 42, newKey).encode()) + assertContentEquals(scope, decoded?.scopeId) + assertEquals(42L, decoded?.epoch) + assertContentEquals(newKey, decoded?.newKey) + assertNull(RekeyPayload.decode(ByteArray(70))) // wrong size + } + + @Test + fun remainingMembersGetTheKeyAndRemovedMembersDoNot() { + // Rotator distributes the new key to Alice and Bob, but not Carol. + val blobs = listOf(blobFor(alice), blobFor(bob)) + val content = ConcordRekey.encodeContent(blobs) + val roundTripped = ConcordRekey.decodeContent(content) + + assertContentEquals(newKey, find(alice, roundTripped)) + assertContentEquals(newKey, find(bob, roundTripped)) + assertNull(find(carol, roundTripped)) // no blob for Carol ⇒ removed + } + + @Test + fun wrongEpochDoesNotMatch() { + val blobs = listOf(blobFor(alice)) + assertNull(find(alice, blobs, epoch = 2L)) // locator is epoch-bound + } + + @Test + fun tagsCarryScopeEpochAndChunk() { + val tags = ConcordRekey.tags(scope, newEpoch, prevEpoch = 0, prevCommit = "ab".repeat(32), chunkIndex = 1, chunkTotal = 3) + assertEquals(scope.toHexKey(), tags.first { it[0] == ConcordRekey.TAG_SCOPE }[1]) + assertEquals("1", tags.first { it[0] == ConcordRekey.TAG_NEWEPOCH }[1]) + val chunk = tags.first { it[0] == ConcordRekey.TAG_CHUNK } + assertEquals("1", chunk[1]) + assertEquals("3", chunk[2]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordVoiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordVoiceTest.kt new file mode 100644 index 0000000000..f7db089e9d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordVoiceTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord07Voice + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordVoiceTest { + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + private val channelId = "42".repeat(32) + + @Test + fun presenceRoundTrips() { + val rumor = VoicePresence.joined(alice, channelId, epoch = 0, identity = "sfu-abc", createdAt = 1_700_000_000L, broker = "https://broker.example") + val info = VoicePresence.parse(rumor) + assertEquals(VoicePresence.KIND, rumor.kind) + assertEquals("sfu-abc", info?.identity) + assertEquals("https://broker.example", info?.broker) + assertEquals(channelId, info?.channelId) + assertEquals(0L, info?.epoch) + assertTrue(info?.joined == true) + } + + @Test + fun onlyUncontestedIdentitiesVerify() { + val aliceP = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-alice", 1L))!! + val bobP = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-bob", 1L))!! + // both Alice and Bob claim the same identity -> contested + val contestedA = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-x", 1L))!! + val contestedB = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-x", 1L))!! + + val verified = VoicePresence.verifiedParticipants(listOf(aliceP, bobP, contestedA, contestedB)) + assertEquals(alice, verified["id-alice"]) + assertEquals(bob, verified["id-bob"]) + assertFalse(verified.containsKey("id-x")) // contested identity omitted + } + + @Test + fun stalePresenceIsNotFresh() { + val info = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id", createdAt = 1_000L))!! + // createdAt is unix seconds; 1_000s -> 1_000_000ms + assertTrue(VoicePresence.isFresh(info, nowMs = 1_000_000L + VoicePresence.STALE_MS)) + assertFalse(VoicePresence.isFresh(info, nowMs = 1_000_000L + VoicePresence.STALE_MS + 1)) + } + + @Test + fun brokerTokenIsSignedByTheVoiceRoomKey() { + val channelSecret = ByteArray(32) { 0x5A } + val voiceSigner = ConcordKeyDerivation.voiceSignerKey(channelSecret, channelId.chunkedToBytes(), epoch = 0) + val url = "https://broker.example" + ConcordBrokerToken.wellKnownPath(voiceSigner.publicKeyHex) + + val event = ConcordBrokerToken.buildAuthEvent(voiceSigner, url, createdAt = 1_700_000_000L) + assertEquals(ConcordBrokerToken.KIND, event.kind) + assertEquals(voiceSigner.publicKeyHex, event.pubKey) // the SFU room = voice key pubkey + assertTrue(event.verify()) + + val header = ConcordBrokerToken.authorizationHeader(event) + assertTrue(header.startsWith("Concord ")) + } + + private fun String.chunkedToBytes(): ByteArray = ByteArray(length / 2) { substring(it * 2, it * 2 + 2).toInt(16).toByte() } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt new file mode 100644 index 0000000000..557c0515ee --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ConcordKeyDerivationTest { + private val secretA = ByteArray(32) { 1 } + private val secretB = ByteArray(32) { 2 } + private val idA = ByteArray(32) { 0x11 } + private val idB = ByteArray(32) { 0x22 } + + // ---- buildInfo layout ----------------------------------------------------- + + @Test + fun buildInfoLayoutWithIdAndEpoch() { + val label = ConcordLabels.CHANNEL // "concord/channel" (15 bytes) + val info = ConcordKeyDerivation.buildInfo(label, idA, epoch = 1) + + // utf8(label) || 0x00 || id[32] || epoch_be8 = 15 + 1 + 32 + 8 = 56 + assertEquals(15 + 1 + 32 + 8, info.size) + assertContentEquals(label.encodeToByteArray(), info.copyOfRange(0, 15)) + assertEquals(0x00.toByte(), info[15]) + assertContentEquals(idA, info.copyOfRange(16, 48)) + // epoch 1 big-endian + assertContentEquals(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 1), info.copyOfRange(48, 56)) + } + + @Test + fun buildInfoOmitsEpochWhenNull() { + val info = ConcordKeyDerivation.buildInfo(ConcordLabels.VOICE_SENDER, idA, epoch = null) + // label + 0x00 + id, no epoch tail + assertEquals(ConcordLabels.VOICE_SENDER.encodeToByteArray().size + 1 + 32, info.size) + } + + @Test + fun buildInfoOmitsIdWhenNull() { + val info = ConcordKeyDerivation.buildInfo(ConcordLabels.CONTROL, id = null, epoch = 5) + // label + 0x00 + epoch_be8 + assertEquals(ConcordLabels.CONTROL.encodeToByteArray().size + 1 + 8, info.size) + } + + // ---- CORD-05 invite bundle key -------------------------------------------- + + /** + * The invite-key HKDF `info` MUST carry the 32-byte all-zero id (CORD-05 A.1: "the id is + * always present, 32 bytes, all-zeroes where a label has no meaningful id" — A.6 lists + * `concord/invite-key` with `id = 0…0`). Omitting it derived a key that could not open a + * reference-client (Armada) bundle — confirmed by decrypting a live Soapbox invite: only the + * zero-id key produced valid JSON. This pins the id in so the derivation can't silently regress. + */ + @Test + fun inviteBundleKeyIncludesZeroId() { + val token = ByteArray(16) { it.toByte() } + val zeroId = ConcordKeyDerivation.hkdf32(token, ConcordKeyDerivation.buildInfo(ConcordLabels.INVITE_KEY, ByteArray(32))) + val idLess = ConcordKeyDerivation.hkdf32(token, ConcordKeyDerivation.buildInfo(ConcordLabels.INVITE_KEY)) + assertContentEquals(zeroId, ConcordKeyDerivation.inviteBundleKey(token)) + assertNotEquals(zeroId.toHexKey(), idLess.toHexKey()) + } + + // ---- groupKey ------------------------------------------------------------- + + @Test + fun groupKeyIsDeterministic() { + val a = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0) + val b = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0) + assertContentEquals(a.secretKey, b.secretKey) + assertContentEquals(a.publicKey, b.publicKey) + assertContentEquals(a.conversationKey, b.conversationKey) + } + + @Test + fun groupKeyProducesValidXOnlyPubkey() { + val gk = ConcordKeyDerivation.groupKey(ConcordLabels.CONTROL, secretA, idA, 0) + assertEquals(32, gk.publicKey.size) + assertTrue(Secp256k1Instance.isPrivateKeyValid(gk.secretKey)) + // pk must be the x-only pubkey of sk + assertContentEquals(KeyPair(privKey = gk.secretKey).pubKey, gk.publicKey) + } + + @Test + fun groupKeyRotatesWithEpoch() { + val e0 = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0) + val e1 = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 1) + assertNotEquals(e0.publicKeyHex, e1.publicKeyHex) + } + + @Test + fun groupKeyIsDistinctAcrossLabelsIdsAndSecrets() { + val base = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0).publicKeyHex + val byLabel = ConcordKeyDerivation.groupKey(ConcordLabels.CONTROL, secretA, idA, 0).publicKeyHex + val byId = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idB, 0).publicKeyHex + val bySecret = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretB, idA, 0).publicKeyHex + assertNotEquals(base, byLabel) + assertNotEquals(base, byId) + assertNotEquals(base, bySecret) + } + + @Test + fun groupKeyConversationKeyRoundTripsSelfEcdh() { + val gk = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 3) + // conversation key is self-ECDH of sk against its own pk + assertContentEquals(Nip44.v2.getConversationKey(gk.secretKey, gk.publicKey), gk.conversationKey) + + val payload = Nip44.v2.encrypt("gm chat", gk.conversationKey).encodePayload() + assertEquals("gm chat", Nip44.v2.decrypt(payload, gk.conversationKey)) + } + + // ---- communityId ---------------------------------------------------------- + + @Test + fun communityIdIsDeterministicAndOwnerBound() { + val owner = KeyPair() + val salt = ConcordKeyDerivation.newOwnerSalt() + val id1 = ConcordKeyDerivation.communityId(owner.pubKey, salt) + val id2 = ConcordKeyDerivation.communityId(owner.pubKey, salt) + assertContentEquals(id1, id2) + assertEquals(32, id1.size) + + // Different salt or different owner ⇒ different id (multiple communities per owner) + val otherSalt = ConcordKeyDerivation.newOwnerSalt() + assertNotEquals(id1.toHexKey(), ConcordKeyDerivation.communityId(owner.pubKey, otherSalt).toHexKey()) + assertNotEquals(id1.toHexKey(), ConcordKeyDerivation.communityId(KeyPair().pubKey, salt).toHexKey()) + } + + // ---- voice keys ----------------------------------------------------------- + + @Test + fun voiceKeysRideEpochAndDifferFromChatKeys() { + val chat = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0).publicKeyHex + val voiceSigner0 = ConcordKeyDerivation.voiceSignerKey(secretA, idA, 0).publicKeyHex + val voiceSigner1 = ConcordKeyDerivation.voiceSignerKey(secretA, idA, 1).publicKeyHex + assertNotEquals(chat, voiceSigner0) + assertNotEquals(voiceSigner0, voiceSigner1) + + val media = ConcordKeyDerivation.voiceMediaKey(secretA, idA, 0) + assertEquals(32, media.size) + val alice = ConcordKeyDerivation.voiceSenderKey(media, "alice") + val bob = ConcordKeyDerivation.voiceSenderKey(media, "bob") + assertEquals(32, alice.size) + assertNotEquals(alice.toHexKey(), bob.toHexKey()) + // deterministic per identity + assertContentEquals(alice, ConcordKeyDerivation.voiceSenderKey(media, "alice")) + } + + // ---- rekey locator -------------------------------------------------------- + + @Test + fun recipientLocatorIsDeterministicAndDirectionalAndEpochBound() { + val rotator = KeyPair().pubKey + val recipient = KeyPair().pubKey + val loc0 = ConcordKeyDerivation.recipientLocator(rotator, recipient, idA, 1) + assertEquals(32, loc0.size) + assertContentEquals(loc0, ConcordKeyDerivation.recipientLocator(rotator, recipient, idA, 1)) + + // direction matters (rotator‖recipient vs recipient‖rotator) + assertNotEquals(loc0.toHexKey(), ConcordKeyDerivation.recipientLocator(recipient, rotator, idA, 1).toHexKey()) + // epoch rotates the locator + assertNotEquals(loc0.toHexKey(), ConcordKeyDerivation.recipientLocator(rotator, recipient, idA, 2).toHexKey()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHashTest.kt new file mode 100644 index 0000000000..155ef9e2a6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHashTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class EditionHashTest { + private val eid = ByteArray(32) { 0xAB.toByte() } + private val content = """{"member":"aa","role_ids":["bb"]}""" + + @Test + fun hashIsDeterministicAnd32Bytes() { + val h1 = EditionHash.hash(eid, 4, null, content) + val h2 = EditionHash.hash(eid, 4, null, content) + assertEquals(32, h1.size) + assertContentEquals(h1, h2) + } + + @Test + fun genesisAndZeroPrevAreDistinct() { + // hasPrev flag differentiates "no previous" (0x00) from an explicit zero hash (0x01) + val genesis = EditionHash.hash(eid, 0, null, content) + val zeroPrev = EditionHash.hash(eid, 0, ByteArray(32), content) + assertNotEquals(genesis.toHexKey(), zeroPrev.toHexKey()) + } + + @Test + fun versionAndContentAndEntityChangeTheHash() { + val base = EditionHash.hash(eid, 4, null, content).toHexKey() + assertNotEquals(base, EditionHash.hash(eid, 5, null, content).toHexKey()) + assertNotEquals(base, EditionHash.hash(eid, 4, null, content + " ").toHexKey()) + assertNotEquals(base, EditionHash.hash(ByteArray(32) { 0xCD.toByte() }, 4, null, content).toHexKey()) + } + + @Test + fun chainLinksThroughPrevHash() { + val v0 = EditionHash.hash(eid, 0, null, """{"name":"general"}""") + val v1 = EditionHash.hash(eid, 1, v0, """{"name":"lounge"}""") + // v1 commits to v0; recomputing v1 with a different prev breaks the link + assertNotEquals(v1.toHexKey(), EditionHash.hash(eid, 1, ByteArray(32), """{"name":"lounge"}""").toHexKey()) + } + + @Test + fun contentIsHashedAsExactBytesNotReserialized() { + // Two byte strings that differ only in whitespace must hash differently, + // proving we hash the wire bytes verbatim. + val compact = EditionHash.hash(eid, 1, null, """{"a":1}""").toHexKey() + val spaced = EditionHash.hash(eid, 1, null, """{ "a": 1 }""").toHexKey() + assertNotEquals(compact, spaced) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt new file mode 100644 index 0000000000..e9fc72f3fb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.envelope + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordStreamEnvelopeTest { + private val authorSigner = NostrSignerInternal(KeyPair()) + private val secret = ByteArray(32) { 7 } + private val channelId = ByteArray(32) { 0x33 } + private val stream = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secret, channelId, 0) + + private fun chatRumor(text: String): Event = + RumorAssembler.assembleRumor( + pubKey = authorSigner.pubKey, + createdAt = 1_700_000_000L, + kind = 9, + tags = arrayOf(arrayOf("channel", "abc"), arrayOf("epoch", "0")), + content = text, + ) + + @Test + fun plaintextSealRoundTrips() = + runTest { + val rumor = chatRumor("hello plaintext") + val wrap = ConcordStreamEnvelope.wrap(rumor, stream, authorSigner, encrypted = false) + + // Wrap is a kind-1059 event authored by the stream address, with an ephemeral p tag. + assertEquals(ConcordStreamEnvelope.KIND_WRAP, wrap.kind) + assertEquals(stream.publicKeyHex, wrap.pubKey) + assertTrue(wrap.verify()) + val pTag = wrap.tags.first { it[0] == "p" } + assertEquals(64, pTag[1].length) + + val opened = ConcordStreamEnvelope.open(wrap, stream) + assertEquals(ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT, opened.sealKind) + assertEquals(authorSigner.pubKey, opened.author) + assertEquals(rumor.id, opened.rumor.id) + assertEquals("hello plaintext", opened.rumor.content) + assertEquals(9, opened.rumor.kind) + } + + @Test + fun encryptedSealRoundTrips() = + runTest { + val rumor = chatRumor("hello encrypted") + val wrap = ConcordStreamEnvelope.wrap(rumor, stream, authorSigner, encrypted = true) + + val opened = ConcordStreamEnvelope.open(wrap, stream) + assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind) + assertEquals(rumor.id, opened.rumor.id) + assertEquals("hello encrypted", opened.rumor.content) + } + + @Test + fun ephemeralWrapUsesKind21059() = + runTest { + val wrap = ConcordStreamEnvelope.wrap(chatRumor("typing"), stream, authorSigner, encrypted = true, ephemeral = true) + assertEquals(ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL, wrap.kind) + assertEquals("typing", ConcordStreamEnvelope.open(wrap, stream).rumor.content) + } + + @Test + fun nonMembersCannotOpen() = + runTest { + val wrap = ConcordStreamEnvelope.wrap(chatRumor("secret"), stream, authorSigner, encrypted = true) + + // A different epoch derives a different stream key ⇒ cannot open. + val otherEpoch = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secret, channelId, 1) + assertNull(ConcordStreamEnvelope.openOrNull(wrap, otherEpoch)) + + // A different secret (non-member) likewise cannot open. + val outsider = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, ByteArray(32) { 9 }, channelId, 0) + assertNull(ConcordStreamEnvelope.openOrNull(wrap, outsider)) + } + + @Test + fun contentIsNotReadableWithoutTheStreamKey() = + runTest { + val wrap = ConcordStreamEnvelope.wrap(chatRumor("no leaks"), stream, authorSigner, encrypted = false) + // The wrap content is NIP-44 ciphertext; the plaintext must not leak into it. + assertTrue(!wrap.content.contains("no leaks")) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt index b375d78031..d7bcc96b86 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt @@ -90,7 +90,7 @@ class RelayAuthenticatorConcurrencyTest { val authenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { _, _ -> emptyList() }, + signWithAllLoggedInUsers = { _, _, _ -> emptyList() }, ) val listener = client.captured diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt new file mode 100644 index 0000000000..e10a5a7c32 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.auth + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * NIP-42: a relay delivers the challenge only in an `AUTH` message, never in a `CLOSED`. When a REQ + * is refused with an `auth-required:` CLOSED *after* the initial AUTH (e.g. a Concord channel-plane + * REQ mounted once the control plane folds in its channel stream keys), the relay does not + * re-challenge — the client is expected to reuse the stored challenge. These tests pin that: + * - a REQ refused with `auth-required:` re-signs against the stored challenge and sends AUTH for + * the newly-available identities; + * - the dedup makes it loop-safe (a second refusal with no new keys sends nothing); + * - a non-`auth-required` CLOSED never triggers a re-auth; + * - the re-auth is non-interactive (never asks the signing lambda to prompt). + */ +class RelayAuthenticatorReauthOnClosedTest { + private class CapturingClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + @Volatile var captured: RelayConnectionListener? = null + + override fun addConnectionListener(listener: RelayConnectionListener) { + captured = listener + } + } + + private class FakeRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + val sent = mutableListOf() + + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) { + sent.add(cmd) + } + + override fun sendIfConnected(cmd: Command) { + sent.add(cmd) + } + + override fun disconnect() = Unit + } + + private fun authedPubKeys(relay: FakeRelayClient) = relay.sent.filterIsInstance().map { it.event.pubKey } + + /** Acks the newest AUTH the client sent, as a well-behaved relay would, so the auth isn't left in flight. */ + private fun ackNewestAuth( + listener: RelayConnectionListener, + relay: FakeRelayClient, + ) { + val newest = + relay.sent + .filterIsInstance() + .last() + .event + listener.onIncomingMessage(relay, "", OkMessage.accepted(newest.id)) + } + + @Test + fun authRequiredClosedReauthsNewlyAvailableKeyReusingStoredChallenge() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + + val control = NostrSignerInternal(KeyPair()) + val channel = NostrSignerInternal(KeyPair()) + + // Starts with only the control identity; the channel identity becomes available later + // (mirrors a Concord control-plane fold revealing a channel stream key). + val available = mutableListOf(control) + val interactiveFlags = mutableListOf() + + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, interactive -> + interactiveFlags.add(interactive) + available.map { it.sign(template) } + }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + listener.onIncomingMessage(relay, "", AuthMessage("chal-1")) + // A well-behaved relay acks the control AUTH, so nothing is left in flight. + ackNewestAuth(listener, relay) + + assertEquals(listOf(control.pubKey), authedPubKeys(relay), "Initial AUTH signs only the control key") + assertEquals(listOf(true), interactiveFlags, "The fresh AUTH challenge is interactive") + + // Control plane folds → the channel stream key is now available. + available.add(channel) + + // The channel-plane REQ is refused because the connection isn't AUTHed as the channel key. + listener.onIncomingMessage( + relay, + "", + ClosedMessage("channel-sub", MachineReadablePrefix.AUTH_REQUIRED.format("authenticate first")), + ) + + assertEquals( + listOf(control.pubKey, channel.pubKey), + authedPubKeys(relay), + "The auth-required CLOSED re-auths, sending AUTH for the newly-available channel key (control deduped)", + ) + assertEquals(false, interactiveFlags.last(), "A re-auth off a CLOSED is non-interactive") + ackNewestAuth(listener, relay) + + // Loop-safety: a second refusal with no new keys must send nothing more. + listener.onIncomingMessage( + relay, + "", + ClosedMessage("channel-sub", MachineReadablePrefix.AUTH_REQUIRED.format("still authenticating")), + ) + assertEquals( + listOf(control.pubKey, channel.pubKey), + authedPubKeys(relay), + "A repeated auth-required CLOSED with no new identity is a no-op (dedup by pubkey+challenge)", + ) + } + + @Test + fun burstOfAuthRequiredClosedsWhileAnAuthIsInFlightIsCoalesced() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val control = NostrSignerInternal(KeyPair()) + val channel = NostrSignerInternal(KeyPair()) + val available = mutableListOf(control) + + var signCalls = 0 + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, _ -> + signCalls++ + available.map { it.sign(template) } + }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + listener.onIncomingMessage(relay, "", AuthMessage("chal-1")) + // Control AUTH is deliberately NOT acked → it stays in flight. + available.add(channel) + val callsBeforeBurst = signCalls + + // A relay refuses every open sub at once. While the control AUTH is unresolved, these must + // NOT each re-sign (which would re-hit an external signer per ledger-ALLOW account). + repeat(5) { + listener.onIncomingMessage( + relay, + "", + ClosedMessage("sub-$it", MachineReadablePrefix.AUTH_REQUIRED.format("auth first")), + ) + } + assertEquals(callsBeforeBurst, signCalls, "No re-sign while an AUTH is still in flight") + + // Once the in-flight AUTH resolves, the next refusal re-auths the newly-available key. + ackNewestAuth(listener, relay) + listener.onIncomingMessage( + relay, + "", + ClosedMessage("sub-x", MachineReadablePrefix.AUTH_REQUIRED.format("auth first")), + ) + assertTrue(authedPubKeys(relay).contains(channel.pubKey), "Channel key is authed once the burst settles") + } + + @Test + fun nonAuthRequiredClosedDoesNotReauth() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val signer = NostrSignerInternal(KeyPair()) + + var signCalls = 0 + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, _ -> + signCalls++ + listOf(signer.sign(template)) + }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + listener.onIncomingMessage(relay, "", AuthMessage("chal-1")) + val afterInitial = signCalls + + listener.onIncomingMessage(relay, "", ClosedMessage("sub", MachineReadablePrefix.ERROR.format("bad req"))) + listener.onIncomingMessage(relay, "", ClosedMessage("sub", MachineReadablePrefix.RESTRICTED.format("nope"))) + + assertEquals(afterInitial, signCalls, "A non-auth-required CLOSED must not trigger a re-auth") + } + + @Test + fun authRequiredClosedBeforeAnyChallengeIsIgnored() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val signer = NostrSignerInternal(KeyPair()) + + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, _ -> listOf(signer.sign(template)) }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + // No AUTH challenge received yet → no stored challenge → nothing to reuse. + listener.onIncomingMessage( + relay, + "", + ClosedMessage("sub", MachineReadablePrefix.AUTH_REQUIRED.format("authenticate first")), + ) + + assertTrue(relay.sent.isEmpty(), "Without a stored challenge there is nothing to re-auth with") + assertFalse(relay.sent.any { it is AuthCmd }) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt index bbd7ad35cf..39926c8406 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt @@ -102,7 +102,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { _, _ -> + signWithAllLoggedInUsers = { _, _, _ -> throw SignerExceptions.TimedOutException("User didn't accept or reject in time.") }, ) @@ -130,7 +130,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { _, _ -> + signWithAllLoggedInUsers = { _, _, _ -> listOf(RelayAuthEvent.create(relay.url, "challenge-123", signer)) }, )