diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..20d24b2
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,1326 @@
+# Concord Protocol Implementation Plan
+
+Implementation plan for adding **Concord** communities and channels to Coop.
+
+- Protocol repo: https://github.com/concord-protocol/concord
+- Protocol docs: https://concordprotocol.org/learn/what-is-concord
+- SDK: [`su.reya:nostr-sdk-kmp:0.3.2`](https://github.com/reyakov/nostr-sdk-ffi)
+
+**Requirements this plan honors**
+
+1. No over-engineering, no over-complication.
+2. Reuse existing APIs and types from the nostr SDK; only create new code where the SDK has nothing.
+3. Follow the existing Coop architecture (no new patterns, no new DI framework, no new persistence layer).
+
+**Status**
+
+| Milestone | State |
+|---|---|
+| M1 — crypto core | ✅ `e221eda` |
+| M2 — plane stack | ✅ `8b0cbd2` |
+| M3 — join + read | ✅ `871efa6` |
+| M4 — write | ✅ `9230628` |
+| M5 — UI | ✅ code complete, compiles on both targets, **has never been run on a device** |
+| **M3/M4 interop smoke test** | ⬜ **not run — this is the acceptance gate** ([§12](#m3-interop-smoke-test--the-acceptance-gate), [§14](#14-verification-plan)) |
+| M6 — reactions / edits | ⬜ optional |
+
+**Out of scope for this app: community creation.** Coop **joins** communities; it never mints one. An
+owner creates a community once — from a desktop client or a reference implementation — and hands
+out invites. See [§3.2](#32-deferred--explicitly-out-of-v1).
+
+**The one thing that matters most:** nothing has ever exchanged a message with a real Concord
+client. Every milestone is verified against itself and against the spec's byte layouts, which
+catches a typo in *our* code but not a misreading of the spec. Only the interop smoke test does
+that, and it needs a human with a second client.
+
+---
+
+## Table of contents
+
+1. [Verdict](#1-verdict)
+2. [Capability check — exists vs. must be written](#2-capability-check--exists-vs-must-be-written)
+3. [Scope — v1 vs. deferred](#3-scope--v1-vs-deferred)
+4. [Architecture](#4-architecture)
+5. [Files](#5-files)
+6. [Crypto layer — exact algorithm](#6-crypto-layer--exact-algorithm)
+7. [Wire format — exact stack](#7-wire-format--exact-stack)
+8. [Subscriptions](#8-subscriptions)
+9. [Persistence](#9-persistence)
+10. [Invites](#10-invites)
+11. [UI](#11-ui)
+12. [Milestones](#12-milestones)
+13. [Risks and open decisions](#13-risks-and-open-decisions)
+14. [Verification plan](#14-verification-plan)
+15. [Appendix A — Concord constants reference](#appendix-a--concord-constants-reference)
+16. [Appendix B — Coop architecture reference](#appendix-b--coop-architecture-reference)
+
+---
+
+## 1. Verdict
+
+Feasible without touching the Rust SDK. Every cryptographic primitive Concord needs is reachable through `nostr-sdk-kmp` **except HKDF-SHA256**, which Okio already gives us the pieces for (`ByteString.hmacSha256`) — and Okio is already a `commonMain` dependency.
+
+The hard part is not crypto. It's that **Concord's wrap is NIP-59 *inverted*** (fixed author, ephemeral `p`), so it cannot ride the existing `MessageManager` giftwrap pipeline. It needs a parallel plane.
+
+---
+
+## 2. Capability check — exists vs. must be written
+
+Verified against the published artifact `su.reya:nostr-sdk-kmp:0.3.2` (sources jar), not just documentation.
+
+### 2.1 Exists — use it, don't wrap it
+
+| Concord need | Existing API | Notes |
+|---|---|---|
+| NIP-44 with an arbitrary keypair | `nip44Encrypt(sk, pk, content, Nip44Version.V2)` `nip44Decrypt(sk, pk, payload)` | Concord's `conv_key = nip44_conversation_key(sk, pk)` **is** NIP-44's conversation key. Pass the plane's own keypair → self-ECDH happens for free. Do **not** hand-roll this. |
+| x-only pubkey from a secret | `Keys(secretKey).publicKey()` | secp256k1 x-only, matches Concord's byte-exact rule |
+| Arbitrary kind numbers | `Kind(1059u)`, `Kind(20013u)`, `Kind(20014u)`, `Kind(3308u)`, `Kind(9u)`… | `Kind(kind: UShort)` is a public constructor |
+| Sign a wrap with a **derived** key | `EventBuilder(kind, content).tags(…).finalize(Keys(sk))` | `Keys` implements `NostrSigner` |
+| Sign a seal with the **user's** key (incl. NIP-46 bunker) | `.finalizeAsync(nostr.signer)` | `UniversalSigner` already proxies this |
+| Decode events | `Event.fromJson`, `UnsignedEvent.fromJson(…).ensureId()`, `Event.verify()` | Already used in `Messaging.extractRumor` |
+| Multi-letter tags | `Tag.custom("channel", listOf("…"))`, `Tag.parse(List)`, `tag.kind()`, `tag.asVec()` | Filters only support **single-letter** tags |
+| Subscribe / publish to specific relays | `client.subscribe(ReqTarget.manual(mapOf(relay to filters)), id)` `client.addRelay(url, RelayCapabilities.read())` `SendEventTarget.to(relays)` | Same idiom as `MessageManager.getUserMessages` |
+| Local persistence | `client.database().saveEvent/query` (LMDB) | Mirror `MessageManager.setCachedRumor` |
+| Secret storage | `AppStorage.setSecret` (AES-GCM via Android Keystore) | Room storage does **not**; roots must use this |
+| NIP-40 expiry | `Tag.expiration(Timestamp)` | For deferred CORD-08 |
+| SHA-256 | Okio `ByteString.sha256()` | `okio` already in `shared/commonMain` |
+| HMAC-SHA256 | Okio `ByteString.hmacSha256(key)` | In `commonMain` → works on iOS targets too |
+| base64url unpadded | `kotlin.io.encoding.Base64.UrlSafe` (Kotlin 2.4) | `Base64` already imported in `BlossomClient` |
+| scalar validity test | `SecretKey.fromBytes(bytes)` throws for invalid scalars | This *is* `scalar_normalize`'s reject branch |
+| hex ↔ bytes | Okio `ByteString.decodeHex()`, `ByteString.hex()` | |
+
+### 2.2 Missing — must be written (small, pure Kotlin)
+
+| Need | Size | Where |
+|---|---|---|
+| **HKDF-SHA256** (Extract + Expand) | ~15 lines on top of Okio | `concord/ConcordCrypto.kt` |
+| `hkdf` info layout (`label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]`) | ~10 lines | same |
+| `scalar_normalize` retry loop | ~10 lines | same |
+| `community_id`, `edition_hash`, `prevcommit` | ~15 lines | same |
+
+Everything else is composition of existing SDK calls.
+
+---
+
+## 3. Scope — v1 vs. deferred
+
+Concord is 8 CORDs. Building all at once contradicts requirement 1.
+
+### 3.1 v1 — ships
+
+| CORD | What ships |
+|---|---|
+| **01** Private Streams | Full wrap/seal/rumor stack: `1059` / `20013` / `20014` + ephemeral `p` |
+| **02** Communities | `community_id`, `community_root`, `control_root` (held, not used), epochs, Control/Chat/Guestbook planes. **Read-only Control fold for `vsk 0` (metadata) + `vsk 2` (channels) only.** Guestbook: publish `join` on join, do not fold. |
+| **03** Channels | Public + Private, key derivation, `channel`/`epoch` binding checks, send/receive `kind 9`, with `kind 7` reactions and `kind 3302` edits folded onto it (M6) |
+| **05** Invites | **Redeem only**: Direct Invite (`3313`) + public link bundle (`33301`) + fragment decoder. No minting. |
+
+### 3.2 Deferred — explicitly out of v1
+
+| CORD / feature | Why deferred |
+|---|---|
+| **Community creation** | **Decided, not deferred: out of scope for mobile.** Coop is a joiner. Owner-side minting (`community_id`, `community_root`, `control_root`, genesis `vsk 0` + `#general` `vsk 2`, written as `20014` plaintext seals) is a desktop/reference-client job. Two things follow from this and are worth stating: `concord/control-signer` is never used here, and the app cannot demo itself — there must be a real community minted elsewhere, which is exactly what the interop smoke test needs. |
+| **04** Roles | Roster fold + `vac` citation + outranking rules. Large. Needed for moderation, not for chat. |
+| **06** Rekeys | Epoch rotation. v1 reads the epoch it was invited to and holds old keys read-only. |
+| **05** minting | Invite List (`13303`), Registry (`vsk 8`), revocation tombstones. |
+| **07** A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. |
+| **08** Disappearing | Cheap to add later; `Tag.expiration` exists. |
+| Pins / Threads / WebXDC | Not needed for a first cut. |
+| Deletes (`kind 5`) | Registered, not folded: a message another client deleted still renders here. Same shape as M6's edits, so ~the same cost when someone asks. |
+| Replies (`kind 1111`) | Reuse `plane.key.rumor` the same way M6 does; the work is the thread UI, not the wire format. |
+| Community List (`33302`) | **Read-only as of M7** (§12): a Community joined in another client now appears here. Writing the List is still deferred — see M7's "not implemented". |
+| Dissolution | Rare path. |
+
+> **Consequence to be honest about:** the v1 Control fold does **not** enforce authorization. A `control_root` holder could publish forged metadata or channels and v1 would display it. This is bounded (only staff hold `control_root`, and the spec itself calls it "a spam gate, never authority") but it **is** a real gap. Document it in code and label the feature beta in the UI.
+
+---
+
+## 4. Architecture
+
+### 4.1 Where it plugs in
+
+```mermaid
+graph TD
+ NF[Notifications stream] --> N[Nostr.handleNotifications]
+ N --> R{route by kind and author}
+ R -->|kind 1059, known plane pk| CM[ConcordManager]
+ R -->|kind 1059, otherwise| MW[MessageManager - unchanged]
+ R -->|rumor kind 3313| CM
+ CM --> CP[ConcordCrypto]
+ CM --> PL[ConcordPlane wrap and unwrap]
+ CM --> ST[ConcordStore]
+ ST --> AS[AppStorage - secrets]
+ ST --> DB[LMDB index events]
+ CM --> CR[ConcordRepository]
+ CR --> CV[ConcordViewModel]
+ CV --> CS[Screens]
+```
+
+### 4.2 The critical integration point
+
+`Nostr.handleNotifications` is a **single notification pump** (`client.notifications()`). Calling `client.notifications()` a second time would race two consumers over the same stream. Concord must therefore be routed *through* the existing loop, with two new branches.
+
+```kotlin
+// shared/.../nostr/Nostr.kt
+class Nostr(...) {
+ val messages = MessageManager(this)
+ val profiles = ProfileManager(this)
+ val relays = RelayManager(this)
+ val concord = ConcordManager(this) // NEW
+```
+
+**Branch A — plane wraps.** Concord wraps are `kind 1059` whose `author` is the plane's stream pubkey. The existing `extractRumor` would call `signer.nip44DecryptAsync(event.author(), …)` on them and fail. Route by author instead:
+
+```kotlin
+KindStandard.GIFT_WRAP -> {
+ if (concord.isPlaneAddress(event.author())) {
+ concord.handlePlaneEvent(event) // own decrypt path
+ } else {
+ giftWrapQueue.send(event) // existing NIP-17 path, untouched
+ }
+}
+```
+
+**Branch B — Direct Invites.** A `kind 3313` invite rides a *standard* NIP-59 wrap (ephemeral author, `p` = recipient, `k` = `3313`), so it flows through the existing `extractRumor` fine — but `ChatRepository.updateRoomState` would then build a garbage `Room` from it. Intercept it before the app-level callback:
+
+```kotlin
+// inside the giftWrapQueue consumer, after extractRumor
+if (rumor != null && concord.onInboxRumor(rumor)) continue
+```
+
+`ConcordManager.onInboxRumor` returns `true` when it consumed the rumor (i.e. it was a `3313`). This keeps all Concord kind knowledge inside the `concord` package, and requires **no changes to `ChatRepository`**.
+
+> Add a comment on `handleNotifications` recording that it must remain the only `client.notifications()` consumer.
+
+---
+
+## 5. Files
+
+### 5.1 New — `shared/src/commonMain/kotlin/su/reya/coop/concord/`
+
+| File | Contents |
+|---|---|
+| `ConcordKind.kt` | All frozen constants in one place: kind numbers, `vsk` registry, permission bits, KDF label strings, tag names. Pure data, no SDK import — a spec revision is a one-file change |
+| `ConcordCrypto.kt` | `hkdfSha256`, `hkdfInfo`, `groupSeed`, `isValidScalar`, `groupKey`, `communityId`, `editionHash`, `prevCommit`, hex/bytes helpers |
+| `ConcordModels.kt` | `Membership`, `CommunityInvite`, `CommunityMeta`, `ChannelMeta`, `ConcordChannel`, `ConcordMessage`, `ControlEdition` |
+| `ConcordPlane.kt` | `SealForm`, `ChatBinding`, `PlaneKey` + `channelPlaneKey` / `guestbookPlaneKey` / `controlPlaneKey`, and the `rumor()` / `wrap()` / `unwrap()` extensions — the CORD-01 stack with the CORD-03 §3 binding built in |
+| `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/#` decoder, relay dictionary |
+| `ConcordCommunityList.kt` | **M7.** CORD-02 §8's wire models and the *reader's* merges: union fragments, newest entry wins, tombstones subtract. Produces `Membership`s through the invite path's own `problems()` / `toMembership()` |
+| `ConcordStore.kt` | Membership persistence (`AppStorage.setSecret`), LMDB index events, Control edition storage |
+| `ConcordControl.kt` | Control fold: group by `eid`, take highest `ev` with intact `ep` chain; project `vsk 0` / `vsk 2` |
+| `ConcordManager.kt` | Subscriptions, relay connect, notification routing, send-message, join/leave |
+
+### 5.2 New — `shared/src/commonMain/kotlin/su/reya/coop/`
+
+**Delivered in M5.**
+
+| File | Contents |
+|---|---|
+| `Community.kt` | Display helpers over the read model — `CommunityState.displayName()` / `.unreadTotal()`, `ConcordMessage.timeLabel()` / `.dayLabel()`. Deliberately *not* a `RoomUiState`-style mirror: a Community's name is already in the fold, so there is no async lookup to model |
+| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, flows forwarded straight from the manager, and one private `attempt` funnel that hops to `defaultDispatcher` and reports instead of throwing |
+| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel`. `previewInvite` / `join` stay **suspend** so the Join screen owns its spinner |
+| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel`, but re-reads on the manager's `revision` counter since a plane message is not pushed as an event |
+
+### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/`
+
+**Delivered in M5.** All five live in one `communities` package, matching the `screens/chat/` convention.
+
+| File | Contents |
+|---|---|
+| `CommunitiesScreen.kt` | Joined communities list + Direct Invite cards + FAB → Join |
+| `CommunityScreen.kt` | Channel list for one community, split public / private |
+| `ChannelScreen.kt` | Channel history, Discord-style. **Not in the original §5.3 list** — it was implied by the §11 table but omitted from the file plan |
+| `JoinCommunityScreen.kt` | Paste link / scan QR → preview card → Join |
+| `CommunityComponents.kt` | `CommunityRow`, `ChannelRow`, `DirectInviteCard`, `CommunityEmptyState`, `BetaNotice`, `shortCommunityId` |
+
+**Deviation:** there is no `CommunityScreenViewModel`. That screen is a pure lookup of one `CommunityState` by id inside the existing `communities` flow, so an entry-scoped view model would have been a file with no state in it.
+
+### 5.4 Tests — **none are kept**
+
+Decision: no test files are maintained for this feature. M1–M4 were verified during development and the checks were then discarded, so the tree carries no Concord sources under `commonTest` or `iosTest`.
+
+The consequence to plan around is in §14 and risk 13.
+
+### 5.5 Modified
+
+Wired in M3 (before the UI existed):
+
+| File | Change |
+|---|---|
+| `shared/.../nostr/Nostr.kt` | `val concord`, `init(dbPath, storage)`, 2 routing branches |
+| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` |
+
+Wired in M5:
+
+| File | Change |
+|---|---|
+| `shared/.../concord/ConcordManager.kt` | `restored` StateFlow, `dismissDirectInvite`, `reset` |
+| `shared/.../concord/ConcordStore.kt` | `clearMemberships` |
+| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(communityId)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity(link)` |
+| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, passed into `App(...)` |
+| `composeApp/.../App.kt` | factory branch, activity-scoped `ConcordViewModel`, 4 × `entry<…>`, snackbar collector |
+| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList`; QR results routed by `parseInviteLink`; `concordViewModel.resetInternalState()` on logout |
+| `composeApp/.../screens/chat/ChatInput.kt` | `onUpload` / `onMicClick` became nullable, so a screen with neither shows a disabled send button instead of two dead ones |
+| `composeApp/.../composeResources/drawable/ic_communities.xml` | icon (empty state) |
+| `composeApp/.../composeResources/drawable/ic_lock.xml` | icon (private channel, beta notice) |
+
+Wired in M6:
+
+| File | Change |
+|---|---|
+| `shared/.../concord/ConcordModels.kt` | `ConcordReaction`; `ConcordMessage.reactions` / `.edited`; `toChannelMessages()` fold |
+| `shared/.../concord/ConcordManager.kt` | `sendChannelReaction`, `sendChannelEdit`, and the private `channelPlane` / `publish` the three senders share |
+| `shared/.../concord/ConcordKind.kt` | `ConcordTag.E`; `ConcordTag.K`'s doc now covers both of its uses |
+| `shared/.../repository/ConcordRepository.kt` | `sendReaction`, `editMessage` |
+| `shared/.../viewmodel/ChannelScreenViewModel.kt` | `sendReaction`, `editMessage`; `reload` compares the whole list, not just its ids |
+| `composeApp/.../screens/chat/ChatScreen.kt` | `ReactionToolbar` no longer `private` |
+| `composeApp/.../screens/chat/ChatMessage.kt` | `MessageReactions` takes `(emojis, total)` instead of `List`, so Channels can share it |
+| `composeApp/.../screens/communities/ChannelScreen.kt` | long-press action row, reaction chips, the Edit flow and its banner |
+
+Wired in M7:
+
+| File | Change |
+|---|---|
+| `shared/.../concord/ConcordCommunityList.kt` | new — §8 wire models, `listedMemberships()` |
+| `shared/.../concord/ConcordManager.kt` | `adoptCommunityList()`, called first in `sync()` |
+
+---
+
+## 6. Crypto layer — exact algorithm
+
+This is the part that must be byte-exact or nothing interoperates. Freeze it in one file, with the governing spec section quoted above each function.
+
+### 6.1 HKDF info layout (CORD-02 A.1)
+
+```
+info = utf8(label) ‖ 0x00 ‖ id[32] ‖ epoch_be[8] // epoch omitted for labels marked "—"
+salt = ∅ (zero-length, NOT 32 zero bytes)
+len = 32
+```
+
+`id` is always present, 32 bytes, all-zeroes where a label has no meaningful id. The epoch is the **only** omittable field.
+
+```kotlin
+fun hkdfInfo(label: String, id: ByteArray, epoch: ULong?): ByteArray {
+ require(id.size == 32)
+ return Buffer().apply {
+ writeUtf8(label) // 1. label bytes
+ writeByte(0) // 2. separator
+ write(id) // 3. id, always present
+ if (epoch != null) writeLong(epoch.toLong()) // 4. BE u64, only when the label has an epoch
+ }.readByteArray()
+}
+
+/** HKDF-SHA256. Okio gives us HMAC-SHA256 on every target. */
+fun hkdfSha256(
+ ikm: ByteArray,
+ info: ByteArray,
+ salt: ByteArray = ByteArray(0), // Concord always omits it; see note below
+ length: Int = 32,
+): ByteArray {
+ // Okio refuses a zero-length HMAC key, so substitute HashLen zero octets — the value RFC
+ // 5869 defines for an absent salt, and one HMAC's block padding makes identical anyway.
+ val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
+ val prk = ikm.toByteString().hmacSha256(saltKey) // Extract(salt, ikm)
+ val out = Buffer()
+ var t = ByteString.EMPTY
+ var counter = 1
+ while (out.size < length) { // Expand
+ t = Buffer().write(t).write(info).writeByte(counter).readByteString()
+ .hmacSha256(prk)
+ out.write(t)
+ counter++
+ }
+ return out.readByteArray(length.toLong())
+}
+```
+
+Two deliberate widenings of the Concord-specialised form, both bought for verification:
+
+1. The general `length` loop (rather than hardcoding 32) unlocks RFC 5869's L=42 and L=82 vectors.
+2. The optional `salt` unlocks Cases 1 and 2; Concord always takes the default. Concord ships
+ no test vectors of its own ("Examples are illustrative, not verifiable test vectors"), so the
+ RFC is the only ground truth available.
+
+### 6.2 `scalar_normalize` (CORD-02 A.3)
+
+> Must yield a valid secp256k1 secret key: if `seed` is not a valid scalar, append one incrementing counter byte to the hkdf `info` and retry, the counter starting at 0. The reject branch is ~2⁻¹²⁸ rare; the counter keeps it deterministic across implementations.
+
+The counter is appended to the **info**, after whatever fields are present.
+
+### 6.3 `group_key` (CORD-02 A.2)
+
+Split in two on purpose: `groupSeed` is pure byte manipulation and therefore unit-testable,
+while `groupKey`'s secp256k1 half needs the SDK, which **cannot load under a host JVM unit
+test** (see risk 13). `isValidScalar` replaces the `SecretKey.fromBytes` probe with a plain
+big-endian range check against the secp256k1 order, so A.3's retry needs no crypto library.
+
+```kotlin
+data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
+
+/** group_key up to and including scalar_normalize: returns the normalized seed. */
+fun groupSeed(
+ label: String,
+ secret: ByteArray,
+ id: ByteArray,
+ epoch: ULong? = null,
+ isValid: (ByteArray) -> Boolean = ::isValidScalar, // seam for A.3's ~2⁻¹²⁸ branch
+): ByteArray {
+ val base = hkdfInfo(label, id, epoch)
+ var counter = -1 // -1 = no counter byte (first attempt)
+ while (counter <= 255) {
+ val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
+ val seed = hkdfSha256(secret, info)
+ if (isValid(seed)) return seed
+ counter++ // A.3: counter starts at 0 on first retry
+ }
+ error("Concord group_key: scalar_normalize exhausted for label $label")
+}
+
+/** A secp256k1 secret key is any integer in [1, n-1]. */
+fun isValidScalar(seed: ByteArray): Boolean {
+ if (seed.size != 32) return false
+ // reject all-zeroes, then big-endian compare against SECP256K1_ORDER
+}
+
+fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
+ val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
+ return GroupKey(secretKey, Keys(secretKey).publicKey())
+}
+```
+
+The `conv_key` from A.2 needs **no** separate implementation: `nip44Encrypt(groupSk, groupPk, …)` derives exactly it.
+
+### 6.4 `community_id` (CORD-02 A.4) — note: **no** `0x00` separator
+
+```kotlin
+fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray =
+ sha256("concord/community".encodeToByteArray(), ownerXonly, ownerSalt)
+```
+
+This is a plain SHA-256 commitment, **not** the HKDF construction.
+
+### 6.5 Labels required in v1
+
+| Label | secret (ikm) | id | epoch |
+|---|---|---|---|
+| `concord/control` | `community_root` | `community_id` | yes |
+| `concord/control-signer` | `control_root` | `community_id` | yes |
+| `concord/channel` | `community_root` (public) *or* `channel_key` (private) | `channel_id` | yes |
+| `concord/guestbook` | `community_root` | `community_id` | yes |
+| `concord/dissolved` | `community_id` | `0…0` | — |
+
+Only `concord/channel`, `concord/guestbook`, and `concord/control` are exercised in v1. `concord/control-signer` is **unused**: it exists to sign Control editions, and nothing in Coop ever writes to the Control plane ([§3.2](#32-deferred--explicitly-out-of-v1)).
+
+### 6.6 Encoding rules (CORD-01, normative)
+
+| Rule | |
+|---|---|
+| Hex is lowercase | Every 32-byte value in fields and tags is 64 lowercase hex chars |
+| Pubkeys are x-only hex, never bech32 | Not `npub`, not 33-byte compressed |
+| Tag values are strings | `"4"`, never `4` |
+| Empty content is `""` | Never `null`, never omitted |
+| `created_at` is unix seconds, **untweaked** | Sub-second ordering rides the `ms` tag |
+
+---
+
+## 7. Wire format — exact stack
+
+### 7.1 Sending a channel message
+
+Implemented in `ConcordPlane.kt`; the whole send path is two calls.
+
+```kotlin
+val plane = channelPlaneKey(secret, channelId, epoch) // CORD-03 §1
+val rumor = plane.rumor(author, ConcordKind.MESSAGE.toUShort(), text, now)
+val wrap = plane.wrap(rumor, signer) // signer = the real author
+```
+
+What `wrap` does, in order:
+
+1. **rumor** — `kind` given, stamped with the binding tags and `ms`, `finalizeUnsigned(author).ensureId()`. Never signed.
+2. **seal** — `kind` = the plane's `SealForm`. `20013` content is the NIP-44 ciphertext of the rumor JSON; `20014` content is that JSON byte-verbatim. `customCreatedAt(rumor.createdAt())`, then `.finalizeAsync(signer)` so the **real author** signs it and a NIP-46 bunker works.
+3. **wrap** — `kind 1059`, content = NIP-44 of the seal JSON, one ephemeral `p` tag, `customCreatedAt(rumor.createdAt())`, `.finalize(Keys(stream.secretKey))` so the **stream key** signs it.
+
+Four things that are easy to get wrong:
+
+- The wrap content is encrypted under the **same conversation key** as the seal. It is double encryption under one key — not a second key, and never the `p`-tagged key.
+- `created_at` is **never tweaked**, and the seal and wrap reuse the rumor's value verbatim so all three layers agree. Sub-second ordering lives in the `ms` tag; true time = `created_at * 1000 + ms`.
+- The seal form is **owned by the plane**, not passed per call (CORD-02 §5), so a chat wrap can never be sealed with `20014`.
+- The NIP-44 65,535-byte plaintext cap is enforced **at build time** before each encryption. A lenient publisher mints events a strict reader cannot decrypt.
+
+### 7.2 Receiving
+
+`PlaneKey.unwrap(event)` runs every check a reader must make and returns null on any failure. Null means **drop**, never retry:
+
+1. `kind` is `1059` — otherwise not ours
+2. author equals the plane's stream pubkey — this is what makes CORD-01's write-restricted split real: a read-key holder can verify a wrap but cannot mint one
+3. `event.verify()` — id and signature
+4. NIP-44 decrypt the wrap content with the **read** key → seal
+5. `seal.verify()`, and `seal.kind()` matches the plane's `SealForm` (the other form is a discipline violation, not a variant)
+6. `20013` → NIP-44 decrypt the seal content; `20014` → take it verbatim, never re-parsed as an event
+7. `UnsignedEvent.fromJson(…).ensureId()`, and `rumor.author() == seal.author()` — NIP-59's impersonation check
+8. the **mandatory** CORD-03 §3 binding check (below)
+
+```
+channel must be present and strict-equal to the plane's channel_id
+epoch must be present and strict-equal to the plane's epoch
+```
+
+On a Chat plane both are required, so neither a re-wrap into another Channel nor a cross-epoch replay survives. The community-wide Guestbook and Control planes split no sub-context, so the spec binds nothing there and the check is a no-op.
+
+### 7.3 Seal discipline (CORD-02 §5) — normative
+
+| Plane | Seal kind |
+|---|---|
+| **Control** | **`20014` plaintext** — a signature over ciphertext could not survive a compaction re-wrap across epochs |
+| Chat, Guestbook, rekey | **`20013` encrypted** |
+
+Modelled as `SealForm`, held by `PlaneKey`, set by the plane factories. Because the plane owns it, `wrap` cannot pick the wrong form and `unwrap` rejects the other form outright.
+
+### 7.4 Event kinds used in v1
+
+| Kind | Function | Plane |
+|---|---|---|
+| `1059` | Stream wrap (durable envelope) | all |
+| `21059` | Ephemeral gift wrap — relays MUST NOT store | all (deferred) |
+| `20013` | Encrypted seal | Chat, Guestbook, rekey |
+| `20014` | Plaintext seal | Control |
+| `9` | Message | Chat |
+| `3308` | Control edition, sub-kinded by `vsk` | Control |
+| `3306` | Join / Leave | Guestbook |
+| `3313` | Direct invite (standard NIP-59 wrap, `k`-tagged) | person-to-person |
+| `33301` | Public invite bundle | outside the wrap |
+| `30078` | *(Coop-internal)* LMDB index bucket for cached rumors | local only |
+
+Full registry: [Appendix A](#appendix-a--concord-constants-reference).
+
+---
+
+## 8. Subscriptions
+
+One subscription id per community, refreshed whenever the relay set or channel set changes:
+
+```kotlin
+val id = "concord:${community.idHex}"
+client.unsubscribe(id)
+
+// 1. connect the community's relays (from the invite, then the Control fold)
+community.relays.forEach {
+ client.addRelay(RelayUrl.parse(it), RelayCapabilities.read())
+ client.connectRelay(RelayUrl.parse(it))
+}
+
+// 2. one filter per plane; group them so each relay gets the ones it should
+val control = Filter().kind(Kind(1059u)).author(controlPk) // signer pk, from the invite
+val guestb = Filter().kind(Kind(1059u)).author(guestbook.pk)
+val channels = channels.map { Filter().kind(Kind(1059u)).author(it.pk) }
+
+val targets = mutableMapOf>()
+community.relays.forEach { url ->
+ targets[RelayUrl.parse(url)] = listOf(control, guestb) + channels
+}
+client.subscribe(ReqTarget.manual(targets), id = id)
+```
+
+You **cannot** filter on `channel` / `epoch` — they are multi-letter tags and `Filter.customTags` only accepts `SingleLetterTag`. Plane-level filtering by author is the correct granularity anyway (one key per plane), and the `channel` / `epoch` binding is checked post-decrypt.
+
+For paginated history, mirror `MessageManager.getUserMessages`: `Filter().kind(Kind(1059u)).author(planePk).limit(n)` with `until` cursors.
+
+> **Relay compatibility.** Concord wraps reverse NIP-59 (fixed author, ephemeral `p`). Relays enforcing the optional NIP-59 `p`-tag guard will drop them. The bootstrap set (`relay.ditto.pub`, `relay.primal.net`, …) is tuned for NIP-17 — **always use the community's relay set from the invite**, never the app defaults.
+
+---
+
+## 9. Persistence
+
+Two mechanisms, both already established in the codebase.
+
+### 9.1 Roots and keys → `AppStorage.setSecret`
+
+Android Keystore-backed AES-GCM. One JSON blob, mirroring `SettingsRepository`'s single-key pattern exactly.
+
+```kotlin
+@Serializable
+data class Membership(
+ val communityId: String, // hex
+ val owner: String, // hex — proves the community_id
+ val ownerSalt: String, // hex
+ val communityRoot: String, // hex 32B — never leave setSecret
+ val rootEpoch: ULong,
+ val controlPk: String?, // hex, from invite; trusted-on-trust until a rotation
+ val controlRoot: String?, // hex — staff only, usually null
+ val relays: List,
+ val name: String?,
+ val channels: List, // id, key(hex), epoch, name, private
+)
+
+@Serializable
+data class StoredChannelKey(
+ val id: String, // hex
+ val key: String, // hex 32B
+ val epoch: ULong,
+ val name: String?,
+ val private: Boolean,
+)
+```
+
+Storage keys: `concord_memberships` via `getSecret` / `setSecret`.
+
+> **Never use `set()`** for these — it is plaintext DataStore.
+
+### 9.2 Community state → LMDB index events
+
+Mirror `MessageManager.setCachedRumor` precisely. Kind `30078` (`APPLICATION_SPECIFIC_DATA`) is *addressable*, so the `d` tag is what makes each row a unique slot.
+
+```kotlin
+val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
+ .tags(listOf(
+ Tag.identifier(wrapId.toHex()), // `d` — unique slot, and dedupe key
+ Tag.custom("r", listOf(scopeIdHex)), // index: channel_id or community_id
+ Tag.custom("k", listOf(rumor.kind().asU16().toString())), // rumor kind
+ ))
+ .finalizeAsync(Keys.generate()) ?: return // throwaway key, as MessageManager does
+client.database().saveEvent(event)
+```
+
+Reading a channel:
+
+```kotlin
+Filter()
+ .kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
+ .reference(channelIdHex)
+```
+
+then `UnsignedEvent.fromJson(it.content())`. The Control plane uses the same mechanism with `r = communityIdHex`.
+
+> **Do not skip `Tag.identifier`.** Without a unique `d`, LMDB replaces every message with the previous one sharing the coordinate, silently eating the history. `MessageManager.setCachedRumor` uses the same trick for the same reason.
+
+### 9.3 Control fold (v1, non-gating)
+
+1. Query all stored editions for the community (`r = communityIdHex`).
+2. Parse to `ControlEdition { vsk, eid, ev, ep, content, actor }`.
+3. Group by `(vsk, eid)`; per group take the **highest `ev`** (refuse to downgrade) whose `ep` chain is intact. Same-version ties break on the **lower rumor id** — never `created_at`.
+4. Project:
+ - `vsk 0` → `CommunityMeta` (name, description, relays, icon, banner, `message_expiration`, `custom`)
+ - `vsk 2` → `ChannelMeta` (name, `private`, `deleted`, `custom`)
+5. Ignore every other `vsk` in v1.
+
+**Not implemented in v1:** authority resolution against the Roster (`vsk 1` / `vsk 3`), `vac` citation validation, and `vsk 4` banlist filtering. See [Risks](#13-risks-and-open-decisions).
+
+### 9.4 Unread badges → memory only
+
+Deliberately the same shape as the chat layer: `Room.unreadCount` is counted in memory, incremented on
+an incoming rumor and zeroed by `markAsRead`, and `ChatRepository.refreshChatRooms` explicitly carries
+the in-memory value across a reload rather than recomputing it. Concord mirrors that exactly.
+
+- `ConcordChannel.unreadCount` is the model the UI reads, and `ConcordManager.refreshPlanes` merges it
+ from an in-memory `Map` so a Control edition cannot silently clear every badge.
+- A message is counted only when it is a `kind 9` on a plane we hold, authored by someone other than us
+ — our own wraps come back through the same subscription, and a re-fetch arrives several times.
+- It does **not** survive a restart. Neither does a DM's. Persisting it would mean a read-marker store
+ (a new key, a new write path) for a badge, which is not worth it in v1.
+
+### 9.5 Message freshness → `revision`
+
+`channelMessages` is an on-demand LMDB query, and nothing in the routing branch told a screen that a
+message had arrived. `ConcordManager.revision` is that signal: a `StateFlow` counter bumped on
+every cached rumor and every local send, which a screen combines with its Channel id to re-query. A
+counter rather than a set of changed scope ids, because two messages in one Channel must both be
+observable. It is one value for the whole manager rather than a flow per Channel, so nothing has to be
+created, subscribed and disposed per room.
+
+---
+
+## 10. Invites
+
+### 10.1 Direct Invite — kind `3313`
+
+Rides a **standard** NIP-59 wrap, so it arrives through the existing giftwrap pipeline and only needs an interception in `handleNotifications`:
+
+| Layer | Kind | Details |
+|---|---|---|
+| Wrap | `1059` | ephemeral single-use author, `["p", ""]`, `["k", "3313"]`, optional NIP-40 `["expiration", ""]` |
+| Seal | `13` | standard NIP-59 — **not** the reversed stream wrap |
+| Rumor | `3313` | `content` = the `CommunityInvite` JSON, `tags: []` |
+
+Indexed fetch, if ever needed: `{"kinds":[1059], "#p":[""], "#k":["3313"]}`.
+
+> The `k` tag is unsigned relay-visible bytes — a hint, never authority. An invite is whatever *unwraps* to a kind `3313` rumor, so accept an untagged one too.
+
+### 10.2 Public link — `$BASE/invite/#`
+
+| Part | Contents |
+|---|---|
+| `naddr` | NIP-19 addressable pointer for `(kind 33301, link_signer, "")` — a **locator**, not a secret |
+| `fragment` | base64url (unpadded) of `[version][flags][relays?][token:16]` — **never sent to any server** |
+| `version` | must be `4`; reject lower as legacy |
+| `flags` | stock-set bit → zero relay bytes follow |
+| relay encoding | `1..254` = dictionary id, `0` = `[len][host]` wss-implied, `255` = `[len][full URL]` |
+| max bootstrap relays | 3 |
+
+Stock dictionary: `1` = `wss://jskitty.com/nostr`, `2` = `wss://asia.vectorapp.io/nostr`, `3` = `wss://relay.ditto.pub`, `4` = `wss://relay.dreamith.to`.
+
+`bundle_key = hkdf(token, "concord/invite-key")` — the token derives exactly this one thing.
+
+### 10.3 Bundle (`CommunityInvite`) and its validation
+
+```jsonc
+{
+ "community_id": "",
+ "owner": "",
+ "owner_salt": "", // verify: community_id == sha256("concord/community" ‖ owner ‖ salt)
+ "community_root": "",
+ "root_epoch": 0,
+ "control_pk": "", // taken on trust — nothing in the bundle can prove it
+ "channels": [ { "id": "", "key": "", "epoch": 1, "name": "testers" } ],
+ "relays": ["wss://…"],
+ "name": "Vector",
+ "icon": { "url": "…", "key": "…", "nonce": "…", "hash": "…" },
+ "expires_at": 1735689600000, // optional, unix MILLISECONDS
+ "creator_npub": "", // optional attribution
+ "label": "Reddit" // optional attribution
+}
+```
+
+**Required validation, in order:**
+
+1. `community_id == sha256("concord/community" ‖ owner ‖ owner_salt)` — refuse the bundle otherwise.
+2. Reject more than a sane channel count (spec's reference ceiling is 256).
+3. Truncate `relays` to the Community's cap (5 recommended).
+4. If `expires_at` is in the past: preview still renders, **joining refuses**.
+
+### 10.4 Join flow
+
+1. Parse link → decode fragment → require `version == 4`.
+2. Derive `bundle_key`.
+3. Fetch coordinate `(33301, link_signer, "")` from the bootstrap relays.
+4. Check liveness — a `["vsk","9"]` tombstone at the coordinate replaces the bundle.
+5. Decrypt content → `CommunityInvite`.
+6. Validate per §10.3.
+7. **Preview only.** No join, no subscribe, no icon fetch, no presence.
+8. On explicit accept: persist `Membership` → connect relays → subscribe → publish Guestbook `join`.
+
+```jsonc
+// kind 3306
+{ "kind": 3306, "pubkey": "", "content": "join",
+ "tags": [ ["ms", "128"], ["invite", "", ""] ] } // invite tag optional
+```
+
+> **`expires_at` unit trap.** The bundle uses unix **milliseconds**; the Invite List entry (`13303`) uses unix **seconds**; the NIP-40 tag uses **seconds**. Three representations of the same instant. Easy silent bug — keep the conversions in one function.
+
+### 10.5 Deep links
+
+Concord invite links are `https:// /invite/#`. The fragment never reaches a server, and Android intent filters strip fragments unreliably — so **paste-text and QR scan are the sanctioned paths**. Do not build an https deep-link filter.
+
+If a deep link is wanted later, add `coop://invite?url=` and let Coop's own handler carry the fragment.
+
+---
+
+## 11. UI
+
+Mirrors the DM screens structurally so it feels native to Coop. **Delivered in M5** — see
+[M5](#m5--ui--done) for what actually shipped and what did not.
+
+| Screen | Mirrors | Notes |
+|---|---|---|
+| `CommunitiesScreen` | `HomeScreen` list + FAB | Community name and channel count, plus Direct Invite cards above the list. **No avatar** for a Community: its icon is an encrypted blob v1 never fetches, so the row shows the placeholder rather than a picture it does not have. Empty state per `ContactListScreen` convention. |
+| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock on private, `No key` on one we cannot read. `#general` arrives with the community's genesis metadata. Carries the beta notice from §11.2. |
+| `ChannelScreen` | `ChatScreen` | Reuses `DateSeparator` and `ChatInput`, plus `ReactionToolbar` and `MessageReactions` since M6. Discord-style (author shown per message) rather than Coop's DM style. The input is replaced by a line of text when the Channel has no key here. **M6:** long-press for the emoji row, an Edit action on your own messages, chips under any message that has reactions. |
+| `JoinCommunityScreen` | `NewChatScreen` | Paste link or QR scan (reuse `LocalScanResult` / `Screen.Scan`), preview card, Join button. |
+
+**Deviation:** `ChannelScreen` is not in the §5.3 file list even though this table always implied it — the file plan simply missed it.
+
+### 11.1 Wiring, following existing conventions
+
+```kotlin
+// MainActivity.kt — lazy field next to chatRepository
+private val concordRepository by lazy {
+ ConcordRepository(NostrManager.instance, AppStore(this@MainActivity), scope)
+}
+
+// App.kt — factory branch
+modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(concordRepository)
+```
+
+Plus, in `App.kt`:
+
+- `val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)` (activity-scoped)
+- `entry { CommunitiesScreen(concordViewModel) }`
+- `entry { key -> … }` (entry-scoped `CommunityScreenViewModel`)
+- `entry { key -> … }` (entry-scoped `ChannelScreenViewModel`, keyed like `Screen.Chat`)
+- a fourth `launch { concordViewModel.errorEvents.collect { snackbarHostState.showSnackbar(it) } }` in the existing `LaunchedEffect` at `App.kt:162-178`
+
+And one entry in `BottomMenuList` (`HomeScreen.kt:785-791`) plus `ic_communities.xml` in `composeApp/src/androidMain/composeResources/drawable/`.
+
+### 11.2 UI honesty
+
+- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community info row. Delivered as `BetaNotice` on `CommunityScreen`.
+- Be explicit that Coop **joins** communities and never creates one ([§3.2](#32-deferred--explicitly-out-of-v1)). Nothing in the UI implies an owner-side flow exists, because there is no owner key here to hold: there is no create action anywhere, and `CommunitiesScreen`'s empty state says the only way in is an invite link.
+
+---
+
+## 12. Milestones
+
+Each milestone ends with something runnable.
+
+### M1 — Crypto core (no UI, no networking) — **done**
+
+`ConcordCrypto.kt` + `ConcordKind.kt`. Committed as `e221eda "add concord crypto"`.
+
+**Verified** with RFC 5869 Test Cases 1–3 and SHA-256 digests computed outside the codebase, then cross-checked against the SDK on the iOS target (`isValidScalar` vs `SecretKey.fromBytes`, `groupKey` → `pk == xonly(sk)`, NIP-44 self-ECDH round trip). Those tests were not kept — see §14.
+
+- RFC 5869 Test Cases 1, 2 & 3 pass ✅
+- `hkdfInfo` matches a golden hex annotated with the A.1 layout ✅
+- `communityId` / `prevCommit` / `editionHash` match digests computed outside the codebase ✅
+- `groupSeed` is deterministic, separates label/id/epoch/secret, and retries with the A.3 counter ✅
+- `isValidScalar` agrees with the secp256k1 range at 0, 1, `n-1`, `n`, above `n` ✅
+- `groupKey` → `pk == xonly(sk)` ✅
+
+### M2 — Plane stack (no networking) — **done**
+
+`ConcordPlane.kt`. Delivers `SealForm`, `ChatBinding`, `PlaneKey` + the three plane factories, `PlaneKey.rumor()`, `PlaneKey.wrap()` and `PlaneKey.unwrap()`.
+
+**Verified** with a throwaway end-to-end check run on the iOS target, then deleted (no test files are kept):
+- build a `kind 9` message for a channel → wrap → unwrap → **byte-identical** rumor JSON, with `channel` / `epoch` / `ms` intact and `created_at` shared by all three layers ✅
+- the wrap carries exactly one ephemeral `p` tag ✅
+- a different channel id, a different epoch, or the Guestbook plane cannot decrypt it ✅
+- a `channel` or `epoch` mismatch, and a rumor with no binding tags at all, are dropped ✅
+- publishing with a signer that is not the rumor's author is refused, and a hand-built wrap whose seal author differs is dropped on read ✅
+- `20014` is rejected on a Chat plane, and `20013` is rejected on the Control plane ✅
+- the Control plane is readable from the **read** key plus the writers' pubkey alone, refuses to wrap, and drops a wrap signed by anyone but staff ✅
+
+### M3 — Join + read
+
+Delivered. `ConcordModels.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordStore.kt`, the `ConcordManager` read path, and the two routing branches in `Nostr.kt`.
+
+**Done when:** pasting a real invite link stores the membership, connects the community's relays, folds metadata + channel list, and renders channel messages from a real community.
+
+What shipped:
+
+- `ConcordInvite.kt` — `parseInviteLink`, `decodeInviteFragment` (CORD-05 §3), the stock relay dictionary, `inviteBundleKey`, `decryptInviteBundle`, `problems()` validation, `relaySet`, `toMembership`
+- `ConcordControl.kt` — `editionOf` + `fold`, with the `ep` chain walk, highest-version-wins and the lower-rumor-id tie-break
+- `ConcordStore.kt` — memberships through `AppStorage.setSecret`, plane rumors through LMDB indexed on `d`/`r`/`k`
+- `ConcordManager.kt` — plane index, `restore()`/`sync()`, `isPlaneAddress`, `handlePlaneEvent`, `onInboxRumor`, `previewInvite`, `join`, `channelMessages`
+- `Nostr.kt` — the plane-author routing branch, the Concord queue, the Direct Invite interception, and `concord.attach(storage)` from `init`
+
+**Verified** with a throwaway check on the iOS target (20 cases, all passing), then deleted:
+- the fragment decodes for the stock flag, explicit dictionary ids, `wss://`-implied hosts and verbatim URLs; unknown ids, short tokens and a wrong version are refused; explicit entries cap at 3 while the stock flag yields the whole dictionary ✅
+- a link round-trips through a real `naddr` (kind `33301`, empty identifier) ✅
+- a valid bundle passes `problems()`; a tampered owner, a malformed salt and 257 channels are refused ✅
+- `bundle_key` round-trips a bundle through NIP-44, and a different token cannot open it ✅
+- `expires_at` converts ms→s and an absent expiry never expires ✅
+- an intact Control chain folds to its highest version; a broken link truncates to the last good one; a missing predecessor disqualifies that version alone; same-version ties break on the lower rumor id; entities fold independently; metadata for another `community_id` is ignored; `deleted` is projected ✅
+- `editionOf` parses a real rumor and refuses `vsk 10` and a missing `ev` ✅
+
+**One bug the check caught:** `inviteBundleKey` first used the 32-byte hex guard, but the token is 16 bytes — every link would have failed to open.
+
+**Not covered by that check:** anything needing the network — fetching a bundle, the subscription, and publishing the Join. Those are the interop smoke test below.
+
+### M3 interop smoke test — the acceptance gate
+
+Against a real Community, with a real invite link:
+
+1. paste the link → the preview shows the right name and channel count, and no `problems`
+2. join → the membership persists across a restart, the community's relays connect, and the Control plane folds metadata + the channel list
+3. **a message from another Concord client appears in Coop** — this is the one nothing else catches, since a label typo breaks interop silently
+4. a message sent from Coop appears there too — the M4 half of the same gate
+5. a reaction from that client groups under the message here, and one sent from Coop groups there — M6, and the same silent-failure shape as step 3
+6. an edit from that client replaces the text here with `(edited)` beside it — M6, and the *least* certain of the six, because `examples.md` calls the edit shape illustrative
+
+If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1).
+If step 4 fails while step 3 passes, the read path is right and the fault is in the publish: the relay set, or the relay dropping these wraps (risk 4).
+
+### M4 — Write — **done**
+
+`ConcordManager.sendChannelMessage`, `unreadCount` / `markChannelRead`, and the `revision` signal a Channel screen re-queries on.
+
+What shipped:
+
+- `sendChannelMessage(channelIdHex, content)` — the send *is* `PlaneKey.rumor` + `PlaneKey.wrap`, so there is no second encrypter to keep in sync with the read path. The rumor is cached locally **before** publishing, so the message is visible even with every relay down, and the subscription's echo of the wrap lands on the same `d` slot instead of duplicating the message. Throws when the Channel is one we hold no key for — a Private Channel is listable without being writable.
+- `unreadCount` / `markChannelRead` and `ConcordChannel.unreadCount` (§9.4), counted from the routing branch rather than from a re-read, so a badge costs no LMDB query per message.
+- `revision: StateFlow` (§9.5), so a Channel screen can tell that something arrived.
+
+**Verified** with a throwaway check on the iOS target (2 cases, all passing), then deleted:
+
+- a message delivered on a Channel plane is routed and raises that Channel's badge — in the map and in `ConcordChannel.unreadCount` — while our own message and a `kind 7` reaction raise nothing ✅
+- `markChannelRead` clears the badge and republishes it, and a re-index carries the badge across rather than resetting it ✅
+- a sent rumor carries exactly `channel` / `epoch` / `ms` at `kind 9`, and the Guestbook and a different Channel both refuse to read it ✅
+
+**Not covered, and not coverable offline:** `sendChannelMessage` needs a live `client`, so the publish itself — relay selection, the ack policy, the relay's echo landing on the same `d` — is exercised only by the interop smoke test. The check above used a `MemoryStorage` fake, so the real Android Keystore path is untouched by it too.
+
+**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop. **This is a network criterion and has not been run** — see §14.
+
+### M4.5 — Community creation — **out of scope**
+
+Removed from the plan: mobile does not create communities. An owner mints `community_id`,
+`community_root`, `control_root` and the genesis editions once, from a desktop or reference client,
+and Coop's job is to join what that produces. The full rationale is in
+[§3.2](#32-deferred--explicitly-out-of-v1).
+
+This was previously listed as "optional, ~150 lines on top of M3". The estimate was probably right,
+and it was genuinely tempting — it is the only way to exercise the Control-plane **write** path, and
+the only way to demo the feature without a second client. It is still the wrong shape for this app.
+
+### M5 — UI — **done**
+
+`ConcordRepository` / view models / 5 screens / `BottomMenuList` entry / 2 icons / error snackbars.
+
+**Done when:** the whole flow works without adb logcat.
+
+What shipped:
+
+- **`ConcordRepository`** — flows forwarded from the manager, and one `attempt` funnel so no screen sees an exception. There is no state of its own: `ConcordManager` already pushes the read model, so a copy here would only be something to keep in sync.
+- **`ConcordViewModel`** (activity-scoped) and **`ChannelScreenViewModel`** (entry-scoped). The Channel one re-reads on `revision` rather than on an event, cancels any in-flight read so a burst of revisions cannot let an older snapshot land last, and only replaces the list when the ids actually changed — so a message in *another* community costs one indexed query and no recomposition.
+- **5 screens** in `screens/communities/`, mirroring `HomeScreen` / `ContactListScreen` / `ChatScreen` / `NewChatScreen` row for row.
+- **Reused, not reimplemented:** `ChatInput` and `DateSeparator` from `screens/chat`. `ChatInput`'s `onUpload` / `onMicClick` became nullable — Concord has neither in v1, and two live-looking buttons that do nothing would be worse than their absence.
+- **Two small backend additions the UI genuinely needed:** `restored`, because an empty membership list means the same thing before and after `restore()`; and `reset` / `clearMemberships`, because membership keys are identity-scoped and must not survive a logout (risk 17).
+- **Logout now drops Concord state.** `HomeScreen`'s logout path calls `resetInternalState()` alongside the account and chat ones.
+- **QR scan routes invite links.** `HomeScreen` checks `parseInviteLink` before `PublicKey.parse`, so scanning an invite lands on the Join screen; `JoinCommunityScreen` also reads `LocalScanResult` when reached from the Communities FAB.
+
+**Verified:** see §14. The UI itself was verified by **compilation on both targets** — `:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` — and by nothing else. **No screen has been run.** `adb`/emulator is the only way to exercise the layout, the nav routes and the snackbar collector, and that has not been done.
+
+### M5.5 — not done, and worth knowing
+
+| Gap | Why it is not there |
+|---|---|
+| **No refresh / retry.** `sync()` is only called at startup and on join. A relay that refuses a subscription at startup is not retried until the app restarts. | Adding a button would not help: `sync()` only re-subscribes, it does not backfill. A real retry needs `fetchEvents`-based backfill on the plane addresses, which is a backend change |
+| **No message backfill.** History is whatever LMDB cached while subscribed. | The same missing `fetchEvents` path |
+| **No replies, attachments, threads, deletes.** | Reactions and edits landed in M6 (§12); the rest are not in v1. |
+| **No `@Preview`.** | None exist in the repo (B.8) |
+| **No deep link for `https://…/invite/…`.** | Would need an intent filter in the manifest; `coop://` handling stays as-is |
+
+### M6 — Reactions and edits — **done**
+
+`kind 7` reactions and `kind 3302` edits, reusing the DM reaction UI. Threads (`kind 1111`) are still
+more work — separate milestone.
+
+What shipped:
+
+- **One write path, three kinds.** `sendChannelMessage` / `sendChannelReaction` / `sendChannelEdit`
+ are all `plane.key.rumor` + `plane.key.wrap` behind one private `publish(plane, kind, content,
+ extraTags)`. There is no second encrypter to keep in sync with the read path — the extra tags are
+ the only thing that differs, and they are the only thing the read fold looks at.
+- **Tags exactly as `examples.md` §2.3 / §2.5 has them**, which is the whole interop risk here:
+ a reaction is `e` (target **rumor** id, never the wrap's) + `p` (target's author) + `k` `"9"`;
+ an edit is `e` alone. Both ride the ordinary encrypted seal at the Channel address, so an edit or
+ reaction reaches exactly the readers the original message did.
+- **The fold is in `toChannelMessages()`**, next to `toConcordMessage()`. Messages are projected
+ first, then reactions and edits are folded on by target id. An edit is applied only when its author
+ matches the message's — the `e` tag is a claim, the seal's author is the proof — and the latest
+ edit by `timestampMs` wins. A reaction for a message we no longer hold is dropped with it.
+- **`ReactionToolbar` and `MessageReactions` are now shared**, not DM-only. `MessageReactions` lost
+ its `ReactionGroup` parameter and takes `(emojis, total)`, which is all it ever drew — the same
+ shape of change M5 made to `ChatInput` when Concord had no upload or mic. `ReactionToolbar` is
+ unchanged, just no longer `private`.
+- **Long-press a message** for the emoji row; an Edit action appears on your own messages, which
+ puts the replacement text in the input under an "Editing a message" banner. Tap anywhere to
+ dismiss. This is an inline row rather than the DM screen's floating toolbar — the DM one is
+ anchored with window coordinates and a backdrop, and none of that buys anything in a plain
+ `LazyColumn`.
+- **A latent M5 bug, fixed:** `ChannelScreenViewModel.reload` compared only message *ids*, so a
+ reaction or an edit — which change a message without changing which messages there are — would
+ have been fetched and then thrown away. It compares the whole list now.
+
+**Verified:** compilation on both targets, as M5 was — `:shared:compileKotlinIosSimulatorArm64`,
+`:shared:compileDebugKotlinAndroid` and `:composeApp:compileDebugKotlinAndroid`, with the recompiled
+`composeApp` classes confirmed by timestamp (a cached `BUILD SUCCESSFUL in <1s` on `:composeApp`
+proves nothing — §14.1).
+
+**Not verified, and worth saying out loud:** this is the first milestone whose *entire* output is a
+wire format nothing in this repository can check. `examples.md` marks the edit shape explicitly
+illustrative ("The CORDs register the kind but don't yet pin its fields"), and a wrong `e`-tag or
+`k`-tag convention fails silently — the reaction simply never groups. The interop smoke test below
+gains two more steps because of it.
+
+---
+
+### M7 — Community List discovery — **done**
+
+An invite hands the keys to *one* device. The only path from "joined in another client" to "visible
+here" is CORD-02 §8's Community List: kind `33302`, NIP-44-encrypted to self, fragmented, whose
+entries carry the whole *join material* — keys included.
+
+What shipped:
+
+- **`ConcordCommunityList.kt`** — the §8 wire models plus the reader's merges, in the same shape as
+ `ConcordInvite.kt`: one file per wire concern.
+- **`ConcordManager.adoptCommunityList()`**, called at the top of `sync()` so adopted Communities are
+ subscribed by the same loop that subscribes the rest. Fetch is `Filter(kind 33302, author = us)`
+ over `ReqTarget.auto` — the pattern `RelayManager.fetchMsgRelays` already uses for finding a
+ self-published document — then `signer.nip44DecryptAsync(me, content)` per event and a union.
+- **§8's encoding, which is the one place this format differs from every other Concord document:**
+ every 32-byte value is unpadded base64url at *any* depth, join material and each `channels` entry
+ included, where the rest of the protocol is lowercase hex (CORD-01, Encoding). Values are decoded
+ strictly — exactly 32 bytes or the entry is dropped — then normalised to hex, so nothing downstream
+ knows the difference.
+- **Reuse, not a parallel copy.** A decoded entry is rebuilt as a `CommunityInvite`, which means the
+ invite path's own `problems()` runs on it: the entry must *prove* its `community_id` as
+ `sha256("concord/community" ‖ owner ‖ owner_salt)`, and its channel ids and keys must be 32 bytes.
+ `toMembership()` then does the projection. Entry data that fails any of that is dropped, not
+ rendered.
+- **Only the merges a reader needs.** The newest entry per Community wins, and a Community whose
+ tombstone is at least as new as its entry reads as left. `seed`, the canonical-bytes tiebreak,
+ fragment repacks and the 65 KiB ceiling are all *writer* rules — they exist to keep two devices
+ byte-identical, and a client that never republishes cannot flap. Fragments are unioned, so a
+ multi-fragment List works; `frags` itself is ignored.
+- **Add-only.** A tombstone for a Community this device does not hold is honoured; nothing already
+ joined here is ever removed, because Coop does not write the List and so cannot tell which of two
+ states is newer.
+- **One log line**, because every failure mode here is silent: the fetch returning nothing, an
+ undecryptable fragment, and an entry failing self-certification all look identical from the UI.
+ `Concord: Community List gave N fragment(s), M membership(s), K new` is the difference between "the
+ feature is broken" and "there was nothing to find".
+
+**Not implemented, deliberately:** writing the List. So a Community joined *in Coop* still does not
+appear in another client, and Coop never republishes a fragment. §8 is explicit about the cost of
+getting a writer wrong — a client that drops a field it does not understand "destroys that material
+on every device its holder owns the moment it republishes" — so the read path is proven first.
+Also absent: `seed`-based epoch backfill (moot while CORD-06 rekeys are deferred) and live
+subscription to `33302` (discovery runs at startup, like everything else in `sync()`).
+
+**Verified:** compilation on both targets — `:shared:compileKotlinIosSimulatorArm64` and
+`:shared:compileDebugKotlinAndroid`. Nothing else: the parse is exercised only by a real List, which
+requires the identity that minted it.
+
+---
+
+## 13. Risks and open decisions
+
+### 13.1 Spec conflicts — resolve before writing byte-exact code
+
+| # | Conflict | Resolution |
+|---|---|---|
+| 1 | **`community_id` owner proof.** CORD-05 §1 writes `sha256(owner ‖ salt)`. `examples.md` §6.1 writes `sha256("concord/community" ‖ owner ‖ salt)`. CORD-02 A.4 (marked *frozen*, normative) writes `sha256(utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32])`. | **Implement A.4.** It is a hard-fail interop check, so validate against a reference implementation at the first opportunity. |
+| 2 | **`expires_at` units differ in three places** — bundle = unix **ms**, Invite List entry = unix **s**, NIP-40 tag = **s**. | Keep all conversions in one function. |
+| 3 | **`vac` on `3303`.** CORD-06 §3 says a rotation cites its Grant, but neither its §1 JSONC nor `examples.md` shows the tag. | Deferring rekeys sidesteps this. |
+| 4 | **What `concord/invite-key` yields.** CORD-05 §2 writes `bundle_key = hkdf(token, "concord/invite-key")` and then `nip44_encrypt(bundle_key, …)`, which reads as a raw conversation key. A.6 lists the label in the *derivation* registry, where every row is fed through `group_key` → `scalar_normalize` → keypair, and elsewhere CORD-01 always writes `conv_key` for the self-ECDH value it feeds `nip44_encrypt`. | **Modelled as `group_key("concord/invite-key", token, 0…0)`.** This is the only reading the existing SDK can implement — it exposes NIP-44 by keypair only, never by conversation key — and it is consistent with every other row in A.6. It is a hard-fail interop check: verify at the smoke test, and if a bundle refuses to open, the raw-conversation-key reading is the alternative and would need NIP-44 hand-rolled. |
+
+### 13.2 Design risks
+
+| # | Risk | Mitigation |
+|---|---|---|
+| 4 | **Relay compatibility.** Reversed NIP-59 wraps may be dropped by relays enforcing the `p`-tag guard. | Always use the community's relay set from the invite. Never the app defaults. |
+| 5 | **No owner recovery, by design.** `community_id` commits to the owner's key; lose it and the community is dead. | Not a risk Coop can mitigate — it never holds an owner key, because it never creates a community. Worth stating on the Join screen so nobody expects a rescue path. |
+| 6 | **`control_pk` is taken on trust** at join — nothing in the invite can prove it. | Build nothing security-relevant on it beyond the subscription address. |
+| 7 | **v1 has no authority enforcement.** Staff hold `control_root`, so blast radius is limited, but a hostile staffer can forge metadata/channels. | Document in code; label beta in UI; close with a CORD-04 milestone. |
+| 8 | **One notification pump.** A second `client.notifications()` consumer would silently split subscriptions. | Comment on `handleNotifications` stating it must stay the only consumer. |
+| 9 | **LMDB replaces addressable events by `d`.** Would silently eat messages. | Always set `Tag.identifier(wrapId)`. |
+| 10 | **No succession ↔ no honest rollback.** Dissolution and refoundings are one-way. | Do not build UI implying otherwise. |
+| 11 | **Spec is young** (71 commits, no reference implementation in-repo, examples explicitly non-normative). | Keep every frozen constant in `ConcordKind.kt` so a spec revision is a one-file change. |
+| 12 | **`scalar_normalize`'s retry branch is ~2⁻¹²⁸ rare.** It will never fire in practice, so a bug there would never surface either. | Split the pure `groupSeed` out of `groupKey` so the counter path is reachable through its `isValid` seam rather than buried behind a crypto call. |
+| 13 | **The nostr SDK cannot run in a host JVM unit test.** Its uniffi bindings are JNA-backed and the Android artifact carries Android-ABI `.so` files, so `testDebugUnitTest` on macOS fails with `UnsatisfiedLinkError: libjnidispatch.jnilib`. Discovered in M1. | Not a problem while tests are not kept, but it does mean **there is no automated regression net** for anything that touches `SecretKey`, `Keys`, `nip44*` or `EventBuilder`. Verification is manual: `:shared:iosSimulatorArm64Test` is the only executable target here that can load the SDK, so a throwaway check in `shared/src/iosTest` is the cheapest way to exercise wire-format code, and the M3 interop smoke test is the real acceptance gate. Keep the SDK-free half in pure functions so it at least *could* be covered without a device. |
+| 14 | **`Nostr` is a Context-free singleton, but Concord's keys need `AppStorage`.** Neither the construction site (`NostrManager.instance`) nor the class has a `Context`. | `Nostr.init(dbPath, storage)` takes it and hands it to `ConcordManager.attach`. The foreground service is the only caller and already runs before any notification is handled, so the ordering is guaranteed. A second `AppStorage` instance in the M5 repository is fine — both wrap the same DataStore. |
+| 15 | **Unread badges live in memory only**, so a restart clears every one of them (§9.4). | This is the DM path's behaviour too (`Room.unreadCount` is likewise in-memory). Persisting it means a read-marker store, a new key and a new write path, all for a badge — worth doing only when a user asks for it. |
+| 16 | **The write path has no offline check.** `sendChannelMessage` needs a live `client`, so nothing kept in the repo exercises a publish, and the `MemoryStorage` fake used by the M4 check never touches Android Keystore. | Acceptance is interop smoke test step 4. Read and write share `PlaneKey.wrap`, and reading is verified independently (M2/M3), so a send-only failure localises to the publish: the relay set, or a relay dropping the wrap (risk 4). |
+| 17 | **Logout had to be taught about Concord.** `AccountRepository.logout` cleared the signer and wiped LMDB but nothing else, so the membership blob — which *is* the keys to every Community (CORD-02 §2) — would have survived into the next identity on the same device, silently granting it seats it never took. | `ConcordManager.reset()` clears the storage key, drops the subscriptions and re-indexes to nothing; `HomeScreen`'s logout path calls it. **Interactive confirmation is out of reach** — `logout` is not suspend and the reset is launched from it — so what is verified is that the call path exists and compiles, not that a logout actually erased the blob on a device. |
+| 18 | **`restored` must not flip back on reset.** If `reset()` set it false, the Communities screen would spin forever after a logout, because nothing re-runs `restore()` until the notification pump is rebuilt and the pump has no restart path. | `restored` means "memberships have been loaded", which stays true after they are dropped. Stated in a comment at the assignment. |
+| 19 | **M6's edit shape is illustrative, not normative.** `examples.md` §2.5 says so in as many words: "The CORDs register the kind but don't yet pin its fields; this shape is illustrative." The reaction shape (§2.3) is pinned — NIP-25 with `e`/`p`/`k` — but the edit's `e`-alone layout is a guess the spec invites others to make differently. | Unfixable from here; it is a spec gap, not an implementation choice. Both shapes are one call to `publish` with a different tag list, so a revision is a one-place change (§11's frozen-constants discipline). Report it upstream rather than papering over it. |
+| 20 | **Enforcement of edits is local.** Our fold refuses an edit whose author is not the message's, but nothing stops another client rendering a forged one — and nothing *should*, since a receiver-side check is the whole enforcement model. | This is the protocol, not a bug: enforcement is rejection ("an action that does not trace to the owner is not authority"). The one thing to keep is the check itself, so a hostile edit never lands *here*. |
+| 21 | **M7 discovery can fail three ways, all silently.** (a) the other client never published a `33302` at all; (b) it published to relays `ReqTarget.auto` does not reach, since Coop's client knows only its bootstrap plus indexer set; (c) the entry fails the self-certifying `community_id` check, which drops it by design. | (c) is correct behaviour. (a) and (b) are indistinguishable from the UI, which is why `adoptCommunityList` logs fragment/entry counts — an empty List and an unreachable one must not look the same. If (b) turns out to be the case, the fix is to add the user's own NIP-65 relays to the client before the fetch, the way `MessageManager.connectMsgRelays` does for chat rooms. |
+
+### 13.3 Closed decision
+
+**Community creation: out of scope.** Coop joins communities and never creates one. Rationale in
+[§3.2](#32-deferred--explicitly-out-of-v1). Consequences: `concord/control-signer` stays unused, the
+Control-plane write path is never exercised, and there is no way to demo the feature without a real
+community minted elsewhere — which is the same dependency the interop smoke test already has.
+
+---
+
+## 14. Verification plan
+
+**No test files are kept** (decision, M2). The checks below were written and run during M1–M6,
+then discarded; the tree carries no Concord sources under `commonTest` or `iosTest`. They are
+recorded here (and per-milestone in §12) because they are what establishes the wire format is
+right, and because whoever next touches this code should re-run the same checks.
+
+The problem this leaves is real and worth stating plainly: this is frozen-by-spec crypto with
+**no spec test vectors** ("Examples are illustrative, not verifiable test vectors"), where a
+byte-layout mistake breaks interop *silently* rather than failing loudly. With no kept suite
+there is no regression net, so:
+
+- **The M3 interop smoke test is the acceptance gate.** Nothing else catches a label typo.
+- **`:shared:iosSimulatorArm64Test` is the only target here that can load the SDK** (risk 13), so
+ a throwaway check in `shared/src/iosTest` — written, run, deleted — is the cheapest way to
+ exercise anything touching `SecretKey`, `Keys`, `nip44*` or `EventBuilder` while developing.
+- Anything SDK-free is deliberately kept in a pure function so it *could* be covered from
+ `commonTest` (JVM and iOS, no device needed) if this decision is ever revisited.
+
+### 14.1 Checks that were run
+
+| Check | Type | Ran on |
+|---|---|---|
+| RFC 5869 TC1 / TC2 / TC3 | known-answer | host JVM + iOS ✅ M1 |
+| `hkdfSha256` rejects L > 255·32 | boundary | host JVM + iOS ✅ M1 |
+| `hkdfInfo(channel, id32, 4u)` / epoch-omitted length | golden hex, annotated with the A.1 layout | host JVM + iOS ✅ M1 |
+| `communityId`, `prevCommit`, `editionHash` | golden SHA-256 computed outside the codebase | host JVM + iOS ✅ M1 |
+| `editionHash` absent-vs-zero-`prev` | negative | host JVM + iOS ✅ M1 |
+| `groupSeed` determinism + label/id/epoch/secret separation | property | host JVM + iOS ✅ M1 |
+| `groupSeed` counter path (counter appended to `info`, starts at 0) | injected failing seed | host JVM + iOS ✅ M1 |
+| `isValidScalar` at 0, 1, `n-1`, `n`, above `n`, short | boundary | host JVM + iOS ✅ M1 |
+| `groupKey` → `pk == xonly(sk)` | property | iOS ✅ M1 |
+| `isValidScalar` vs `SecretKey.fromBytes` on the same seeds | cross-check | iOS ✅ M1 |
+| `groupKey` → `nip44` self-ECDH round trip, and a different plane cannot read it | smoke | iOS ✅ M1 |
+| hex round trip, lowercase, 64 chars | unit | host JVM + iOS ✅ M1 |
+| wrap → unwrap round trip is **byte-identical** | integration, no network | iOS ✅ M2 |
+| wrap carries exactly one ephemeral `p` tag; `created_at` shared by all three layers | property | iOS ✅ M2 |
+| wrong channel id / wrong epoch / wrong plane cannot decrypt | negative | iOS ✅ M2 |
+| impersonation, refused on publish **and** dropped on read | negative | iOS ✅ M2 |
+| `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 |
+| `20013` vs `20014` discipline, both directions | negative | iOS ✅ M2 |
+| Control readable from the read key + writers' pubkey alone; refuses to wrap; drops a non-staff wrap | integration | iOS ✅ M2 |
+| 32 bytes → base64url = 43 chars, unpadded | unit | iOS ✅ M3 |
+| `expires_at` ms/s conversion | unit | iOS ✅ M3 |
+| a delivered Channel message raises the badge; our own message and a non-message raise nothing | property | iOS ✅ M4 |
+| `markChannelRead` clears and republishes; a re-index carries the badge across | property | iOS ✅ M4 |
+| a sent rumor is exactly `channel`/`epoch`/`ms` at `kind 9`, refused by the Guestbook and another Channel | unit | iOS ✅ M4 |
+| `:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` after the M5 wiring, with the new classes confirmed present in `build/` | compile | Android + iOS ✅ M5 |
+| The same three tasks after the M6 fold + UI, with the recompiled classes confirmed by timestamp | compile | Android + iOS ✅ M6 |
+| A reaction or edit sent from Coop is understood by another Concord client | **not run** | needs a second client |
+| A reaction or edit from another client folds into Coop's timeline | **not run** | needs a second client |
+| Icons render as intended | **not run** | needs a device |
+| Every M5 screen renders, navigates and snackbars | **not run** | needs `adb` |
+| Logout actually erases the membership blob | **not run** | needs a device (risk 17) |
+
+**RFC 5869 Test Case 1** (for reference):
+
+```
+IKM = 0x0b × 22
+salt = 0x000102030405060708090a0b0c
+info = 0xf0f1f2f3f4f5f6f7f8f9
+L = 42
+PRK = 0x077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5
+OKM = 0x3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
+```
+
+**RFC 5869 Test Case 3** (zero-length salt — closest to Concord's usage):
+
+```
+IKM = 0x0b × 22
+salt = (empty)
+info = (empty)
+L = 42
+PRK = 0x19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04
+OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8
+```
+
+**Test Case 2** (80-octet inputs, L = 82) is likewise a known-answer vector here. It is worth
+using even though Concord never asks for more than 32 bytes: it is the only vector that drives
+Expand across three blocks, and it is what caught the empty-salt handling.
+
+Additionally, run one **interop smoke test** against a real community before declaring the feature done. Nothing catches a label typo faster.
+
+---
+
+## Appendix A — Concord constants reference
+
+### A.1 Durable plane kinds (inner rumor kinds)
+
+| Kind | Function | Plane |
+|---|---|---|
+| `9` | Message (NIP-C7 shape) | Chat |
+| `1111` | Threaded reply (NIP-22 shape) | Chat |
+| `7` | Reaction (NIP-25 shape) | Chat |
+| `5` | Delete (NIP-09 shape) | Chat |
+| `1740` | Timer notice (CORD-08 §4) | Chat |
+| `3302` | Edit | Chat |
+| `3303` | Rekey blobs (CORD-06) | rekey addresses |
+| `3306` | Join / Leave | Guestbook |
+| `3308` | Control edition, sub-kinded below | Control |
+| `3309` | Kick | Guestbook |
+| `3310` | WebXDC peer signal | Chat |
+| `3312` | Guestbook snapshot, chunked, refounder-signed | Guestbook |
+
+### A.2 Ephemeral kinds (outer wrap `21059`)
+
+| Kind | Function | Plane |
+|---|---|---|
+| `23311` | Typing indicator | Chat |
+| `23313` | Voice presence (CORD-07) | Chat |
+
+### A.3 Kinds outside the wrap
+
+| Kind | Function |
+|---|---|
+| `33301` | Public invite bundle — addressable, signed by its per-link keypair at an empty `d`, `vsk` `6` (live) / `9` (revocation tombstone) |
+| `33302` | Community List — addressable, one event per fragment at `d` = fragment index, NIP-44 to self |
+| `13303` | Invite List — replaceable, NIP-44 to self |
+
+### A.4 Control edition sub-kinds (`vsk`)
+
+| vsk | Entity |
+|---|---|
+| `0` | Community metadata |
+| `1` | Role |
+| `2` | Channel metadata |
+| `3` | Grant |
+| `4` | Banlist |
+| `5` | *reserved* (role ordering) |
+| `6`, `9` | *claimed* by the addressable invite marker |
+| `7` | *retired* (v1 owner attestation) |
+| `8` | Invite-link registry |
+| `10` | Dissolved tombstone — chainless, exempt from version discipline |
+| `11` | Pin List |
+
+### A.5 Tags
+
+| Tag | Where | Semantics |
+|---|---|---|
+| `["ms", "<0..999>"]` | every rumor | true time = `created_at * 1000 + ms`; outside 0..999 is malformed → drop |
+| `["p", ""]` | outer stream wrap | NIP-59 reversed: fixed author, ephemeral `p` |
+| `["channel", ""]` | every Chat rumor | MUST be committed inside the author-signed rumor; receiver MUST strict-equal check |
+| `["epoch", ""]` | every Chat rumor | same strict-equal check |
+| `["q", "", "", ""]` | `kind 9` inline quote | NIP-C7 |
+| `K`/`E`/`P`, `k`/`e`/`p` | `kind 1111` reply | uppercase = thread root, lowercase = immediate parent (all rumor ids) |
+| `["vsk", ""]` | `kind 3308` | entity type |
+| `["eid", ""]` | `kind 3308` | stable coordinate |
+| `["ev", ""]` | `kind 3308` | this edition's version, climbs from 1 |
+| `["ep", ""]` | `kind 3308` | previous edition hash; absent on the first |
+| `["vac", "", "", ""]` | `kind 3308`, `3309` | authority citation; absent when the owner acts |
+| `["expiration", ""]` | Chat wrap + rumor | NIP-40; MUST be on both, same value; never on `kind 5` or `1740` |
+| `["d", ""]` | `kind 33301` | empty identifier — the per-link pubkey makes the coordinate unique |
+| `["d", ""]` | `kind 33302` | fragment index in decimal |
+| `["snap", "", "", ""]` | `kind 3312` | snapshot chunk; one id + one `created_at` across all n |
+| `["invite", "", ""]` | `kind 3306` join | optional invite attribution |
+| `["k", "3313"]` | outer Direct Invite wrap | the one deliberate outer-tag exception besides `expiration` |
+
+### A.6 Permission bits (CORD-04 §3)
+
+| Bit | Permission |
+|---|---|
+| `1<<0` | `MANAGE_ROLES` |
+| `1<<1` | `MANAGE_CHANNELS` |
+| `1<<2` | `MANAGE_METADATA` |
+| `1<<3` | `KICK` |
+| `1<<4` | `BAN` |
+| `1<<5` | `MANAGE_MESSAGES` |
+| `1<<6` | `CREATE_INVITE` |
+| `1<<7` | retired (was `MANAGE_INVITES`) |
+| `1<<8` | `VIEW_AUDIT_LOG` |
+| `1<<9` | `MENTION_EVERYONE` |
+| `1<<10`, `1<<12` | reserved |
+| `1<<11` | `PIN_MESSAGES` |
+
+**Staff** = any of `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `BAN`, `CREATE_INVITE`, `PIN_MESSAGES`, plus always the owner. Staff hold `control_root`.
+
+`position` orders authority, **lower is higher**; owner is position 0 and never a Role; a member's rank is the lowest position among their Roles; an actor must **strictly** outrank its target.
+
+### A.7 KDF label registry (CORD-02 A.6)
+
+| Label | secret (ikm) | id | epoch | Yields |
+|---|---|---|---|---|
+| `concord/channel` | channel key *or* `community_root` | `channel_id` | yes | a Channel's group key |
+| `concord/control` | `community_root` | `community_id` | yes | Control Plane **read** key |
+| `concord/control-signer` | `control_root` | `community_id` | yes | Control Plane signer (staff only) |
+| `concord/rekey-pseudonym` | prior `community_root` | `channel_id` | new_epoch | a channel rekey address |
+| `concord/base-rekey-pseudonym` | prior `community_root` | `community_id` | new_epoch | a base rekey address |
+| `concord/recipient-pseudonym` | `rotator_xonly ‖ recipient_xonly` | `scope_id` | new_epoch | a rekey blob locator |
+| `concord/guestbook` | `community_root` | `community_id` | yes | Guestbook Plane group key |
+| `concord/voice-signer` | channel key *or* `community_root` | `channel_id` | yes | SFU room keypair |
+| `concord/voice-media` | channel key *or* `community_root` | `channel_id` | yes | 32-byte call media key |
+| `concord/voice-sender` | `voice_media_key` | `sha256(identity)` | — | per-sender frame key |
+| `concord/dissolved` | `community_id` | `0…0` | — | dissolution tombstone address |
+| `concord/grant` | `community_id` | `member_xonly` | — | a member's Grant coordinate |
+| `concord/banlist` | `community_id` | `0…0` | — | Banlist coordinate |
+| `concord/pins` | `community_id` | `channel_id` | — | a Channel's Pin List coordinate |
+| `concord/invite-links` | `community_id` | `creator_xonly` | — | a creator's Registry coordinate |
+| `concord/invite-key` | `token` | `0…0` | — | public-invite decrypt key |
+| `concord/invite-locator`, `concord/invite-signer` | — | — | — | *retired* |
+
+### A.8 Other frozen derivations
+
+```
+community_id = sha256( utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32] )
+
+prevcommit = sha256( utf8("concord/epoch-key-commitment") ‖ prev_epoch_be[8] ‖ prev_key[32] )
+
+dissolved_pk = group_key("concord/dissolved", community_id, 0…0).pk
+
+edition_hash = sha256(
+ len64(label) ‖ label // label = utf8 "vector-community/v1/edition"
+ ‖ entity_id[32]
+ ‖ version_be[8]
+ ‖ (prev ? 0x01 ‖ prev[32] : 0x00 ‖ zero[32])
+ ‖ len64(content) ‖ content ) // content bytes verbatim, never re-serialized
+```
+
+> `edition_hash`'s label string is literally `vector-community/v1/edition` in the spec — the reference implementation's `vector` prefix, *not* the `concord/` prefix used by every KDF label. Reproduce it exactly.
+
+---
+
+## Appendix B — Coop architecture reference
+
+Points that constrain the implementation. Verified against the current tree (`feat/group-chat`, clean).
+
+### B.1 Module layout
+
+| Module | Targets | Notes |
+|---|---|---|
+| `:shared` | `androidTarget`, `iosArm64`, `iosSimulatorArm64` | All domain/data code in `commonMain` |
+| `:composeApp` | `androidTarget` **only** | `androidMain` only — **no `commonMain` UI, no working iOS app** |
+
+`shared` has Compose **runtime** but not UI/foundation/material3, so it can use `mutableStateOf` but cannot declare composables. **All new screens go in `composeApp/src/androidMain`.**
+
+### B.2 Dependency wiring
+
+No DI framework. The graph is hand-built as `by lazy` fields on `MainActivity`, passed positionally into `App(...)`, then:
+
+- global values via `staticCompositionLocalOf` (`LocalNavigator`, `LocalProfileCache`, `LocalSettings`, `LocalConnectivity`, `LocalSnackbarHostState`, `LocalScanResult`)
+- view models via an ad-hoc `ViewModelProvider.Factory` with a `when` on `modelClass`
+- Activity-scoped VMs created above `NavDisplay`; parameterized VMs created inside `entry<…>` with a `key`
+
+Repositories receive the Activity's `MainScope()` and hop to `Dispatchers.Default` per call via a `defaultDispatcher` constructor param. They never create their own scope.
+
+### B.3 Navigation
+
+JetBrains Navigation 3. Routes are a `@Serializable sealed interface Screen : NavKey`, a `rememberNavBackStack(Screen.Home)`, a `NavDisplay` with `entryProvider { entry { … } }`, and a tiny `Navigator` wrapper exposing `navigate(route)` / `goBack()`.
+
+Adding a screen = declare the route, create the file, register the entry, wire the VM (if any), wire the repository (if any), add a nav entry point and an icon.
+
+### B.4 View models
+
+`androidx.lifecycle.ViewModel` subclasses in `shared/commonMain`. Two shapes:
+
+- **Façade** (`ChatViewModel`, 21 lines): re-exposes repository `StateFlow`s 1:1, forwards calls, `ErrorHost by repository`.
+- **Stateful screen VM** (`ChatScreenViewModel`): `mutableStateListOf` / `mutableStateOf` + `viewModelScope`, still `ErrorHost by repository`.
+
+UI collects with `collectAsStateWithLifecycle()`.
+
+### B.5 Errors
+
+`ErrorHost` (`errorEvents: SharedFlow` + `showError(message)`) with `createErrorHost()`.
+
+Chain: repository `: ErrorHost by createErrorHost()` → view model `: ErrorHost by repository` → one collector per VM in `App.kt:162-178` pushing into `snackbarHostState`.
+
+A new feature's errors are only visible if a collector is added there.
+
+### B.6 Persistence
+
+| Mechanism | Use |
+|---|---|
+| `AppStorage.get/set` | Plaintext DataStore preferences |
+| `AppStorage.getSecret/setSecret` | AES-GCM via Android Keystore (`_encrypted` + `_iv`) |
+| LMDB (`client.database()`) | Nostr events, at `filesDir/nostr`; wiped by `nostr.prune()` on logout |
+
+Current keys: `app_settings` (plain), `user_signer` / `app_keys` (secret), `notification_banner_dismissed` (plain).
+
+The **only** JSON persistence pattern is `SettingsRepository`: one key, `Json { ignoreUnknownKeys = true; encodeDefaults = true }`, a `MutableStateFlow`, and `update(transform)`.
+
+### B.7 Established idioms to mirror
+
+| Concern | Mirror |
+|---|---|
+| Synthetic LMDB index rows | `MessageManager.setCachedRumor` / `getCachedRumor` |
+| Relay connect + subscribe | `MessageManager.getUserMessages` |
+| Secret-backed single-blob repository | `SettingsRepository` |
+| Repository shape | `ChatRepository` (`ErrorHost`, `MutableStateFlow`, `stateIn(scope, WhileSubscribed(5000), …)`, `catch (e: CancellationException) { throw e }`) |
+| List row / empty state / dialogs | `HomeScreen`, `ContactListScreen`, `SettingsScreen` |
+
+### B.8 Known constraints
+
+- `Nostr.handleNotifications` must remain the **only** `client.notifications()` consumer.
+- `shared` cannot declare composables.
+- No `@Preview` composables exist anywhere — don't introduce one just for this feature.
+- All user-facing text is hardcoded English literals; there is no `Res.string` convention. Matching existing convention means inline literals.
+- `client.database().saveEvent` replaces addressable events by `d` tag.
+- `concord`'s repository must be constructed in `MainActivity`, not in `shared`.
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_communities.xml b/composeApp/src/androidMain/composeResources/drawable/ic_communities.xml
new file mode 100644
index 0000000..5235323
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_communities.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_lock.xml b/composeApp/src/androidMain/composeResources/drawable/ic_lock.xml
new file mode 100644
index 0000000..39fced1
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_lock.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
index 786d339..9d2f611 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
@@ -39,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
+import su.reya.coop.repository.ConcordRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen
@@ -54,9 +55,15 @@ import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.SettingsScreen
import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.screens.chat.ChatScreen
+import su.reya.coop.screens.communities.ChannelScreen
+import su.reya.coop.screens.communities.CommunitiesScreen
+import su.reya.coop.screens.communities.CommunityScreen
+import su.reya.coop.screens.communities.JoinCommunityScreen
import su.reya.coop.viewmodel.AccountViewModel
+import su.reya.coop.viewmodel.ChannelScreenViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel
+import su.reya.coop.viewmodel.ConcordViewModel
import su.reya.coop.viewmodel.ProfileCache
import su.reya.coop.viewmodel.SettingsViewModel
@@ -90,6 +97,7 @@ fun App(
profileCache: ProfileCache,
accountRepository: AccountRepository,
chatRepository: ChatRepository,
+ concordRepository: ConcordRepository,
settingsRepository: SettingsRepository,
connectivityMonitor: ConnectivityMonitor,
) {
@@ -109,6 +117,10 @@ fun App(
settingsRepository
)
+ modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(
+ concordRepository
+ )
+
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
@@ -120,6 +132,7 @@ fun App(
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
+ val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)
val context = LocalContext.current
val activity = context as? ComponentActivity
@@ -170,6 +183,11 @@ fun App(
snackbarHostState.showSnackbar(message)
}
}
+ launch {
+ concordViewModel.errorEvents.collect { message ->
+ snackbarHostState.showSnackbar(message)
+ }
+ }
launch {
profileCache.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
@@ -237,7 +255,7 @@ fun App(
),
entryProvider = entryProvider {
entry {
- HomeScreen(accountViewModel, chatViewModel)
+ HomeScreen(accountViewModel, chatViewModel, concordViewModel)
}
entry {
RequestListScreen(chatViewModel)
@@ -297,6 +315,36 @@ fun App(
entry {
SettingsScreen(settingsViewModel)
}
+ entry {
+ CommunitiesScreen(concordViewModel)
+ }
+ entry { key ->
+ JoinCommunityScreen(concordViewModel, key.link)
+ }
+ entry { key ->
+ CommunityScreen(key.communityId, concordViewModel)
+ }
+ entry { key ->
+ val factory = remember(key) {
+ object : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST")
+ return ChannelScreenViewModel(
+ key.communityId,
+ key.channelId,
+ accountRepository,
+ concordRepository
+ ) as T
+ }
+ }
+ }
+ ChannelScreen(
+ viewModel(
+ key = "${key.communityId}:${key.channelId}",
+ factory = factory
+ )
+ )
+ }
}
)
}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
index 86b9171..45e64a7 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
@@ -13,6 +13,7 @@ import kotlinx.coroutines.MainScope
import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
+import su.reya.coop.repository.ConcordRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache
@@ -52,6 +53,10 @@ class MainActivity : ComponentActivity() {
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
}
+ private val concordRepository by lazy {
+ ConcordRepository(NostrManager.instance, scope)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
throwable.printStackTrace()
@@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() {
profileCache = profileCache,
accountRepository = accountRepository,
chatRepository = chatRepository,
+ concordRepository = concordRepository,
settingsRepository = settingsRepository,
connectivityMonitor = connectivityMonitor,
)
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt
index cc3b4e0..cc92896 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt
@@ -61,4 +61,16 @@ sealed interface Screen : NavKey {
@Serializable
data object Settings : Screen
+
+ @Serializable
+ data object Communities : Screen
+
+ @Serializable
+ data class Community(val communityId: String) : Screen
+
+ @Serializable
+ data class Channel(val communityId: String, val channelId: String) : Screen
+
+ @Serializable
+ data class JoinCommunity(val link: String? = null) : Screen
}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt
index 347727d..0f392f9 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt
@@ -63,7 +63,7 @@ class NostrForegroundService : Service() {
// Initialize Nostr client
try {
- nostr.init(dbDir.absolutePath)
+ nostr.init(dbDir.absolutePath, AppStore(this@NostrForegroundService))
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e)
}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
index 7f29360..63aa6b6 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
@@ -102,17 +102,20 @@ import su.reya.coop.RoomKind
import su.reya.coop.RoomUiState
import su.reya.coop.Screen
import su.reya.coop.ago
+import su.reya.coop.concord.parseInviteLink
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
+import su.reya.coop.viewmodel.ConcordViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
accountViewModel: AccountViewModel,
- chatViewModel: ChatViewModel
+ chatViewModel: ChatViewModel,
+ concordViewModel: ConcordViewModel,
) {
val context = LocalContext.current
val navigator = LocalNavigator.current
@@ -166,16 +169,22 @@ fun HomeScreen(
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
- runCatching { PublicKey.parse(result) }
- .onSuccess { pubkey ->
- try {
- val roomId = chatViewModel.createChatRoom(listOf(pubkey))
- navigator.navigate(Screen.Chat(roomId))
- } catch (e: Exception) {
- e.message?.let { snackbarHostState.showSnackbar(it) }
+ // A Concord invite is a link, not a npub, so it is routed to the Join screen instead of
+ // being force-fed to PublicKey.parse.
+ if (parseInviteLink(result) != null) {
+ navigator.navigate(Screen.JoinCommunity(result))
+ } else {
+ runCatching { PublicKey.parse(result) }
+ .onSuccess { pubkey ->
+ try {
+ val roomId = chatViewModel.createChatRoom(listOf(pubkey))
+ navigator.navigate(Screen.Chat(roomId))
+ } catch (e: Exception) {
+ e.message?.let { snackbarHostState.showSnackbar(it) }
+ }
}
- }
- .onFailure { e -> println("Failed to parse QR: ${e.message}") }
+ .onFailure { e -> println("Failed to parse QR: ${e.message}") }
+ }
// Clear the nav state
qrScanResult.clear()
}
@@ -473,7 +482,8 @@ fun HomeScreen(
BottomMenuList(
onDismiss = dismissAndRun,
accountViewModel = accountViewModel,
- chatViewModel = chatViewModel
+ chatViewModel = chatViewModel,
+ concordViewModel = concordViewModel
)
}
}
@@ -788,12 +798,14 @@ fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit,
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel,
+ concordViewModel: ConcordViewModel,
) {
val navigator = LocalNavigator.current
val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { navigator.navigate(Screen.ContactList) },
+ "Communities (beta)" to { navigator.navigate(Screen.Communities) },
"Relay Management" to { navigator.navigate(Screen.Relay) },
"Settings" to { navigator.navigate(Screen.Settings) }
)
@@ -824,6 +836,8 @@ fun BottomMenuList(
accountViewModel.logout(onLogout = {
accountViewModel.resetInternalState()
chatViewModel.resetInternalState()
+ // Concord's keys are identity-scoped, so they leave with the identity.
+ concordViewModel.resetInternalState()
})
}
},
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt
index e1300a5..49ec805 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt
@@ -38,8 +38,8 @@ fun ChatInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
- onUpload: () -> Unit,
- onMicClick: () -> Unit
+ onUpload: (() -> Unit)? = null,
+ onMicClick: (() -> Unit)? = null
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@@ -60,12 +60,16 @@ fun ChatInput(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Default
),
- leadingIcon = {
- IconButton(onClick = onUpload) {
- Icon(
- painter = painterResource(Res.drawable.ic_add_circle),
- contentDescription = "Upload",
- )
+ leadingIcon = if (onUpload == null) {
+ null
+ } else {
+ {
+ IconButton(onClick = onUpload) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_add_circle),
+ contentDescription = "Upload",
+ )
+ }
}
},
)
@@ -75,9 +79,21 @@ fun ChatInput(
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
label = "send_mic_transition"
) { isNotEmpty ->
- if (isNotEmpty) {
+ // Nothing to send and nowhere to dictate to: the send button stays, greyed out.
+ if (!isNotEmpty && onMicClick != null) {
+ FilledTonalIconButton(
+ onClick = onMicClick,
+ modifier = Modifier.size(56.dp),
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_audio),
+ contentDescription = "Speech to Text"
+ )
+ }
+ } else {
IconButton(
onClick = onSend,
+ enabled = isNotEmpty,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -89,16 +105,6 @@ fun ChatInput(
contentDescription = "Send"
)
}
- } else {
- FilledTonalIconButton(
- onClick = onMicClick,
- modifier = Modifier.size(56.dp),
- ) {
- Icon(
- painter = painterResource(Res.drawable.ic_audio),
- contentDescription = "Speech to Text"
- )
- }
}
}
}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt
index 8c7f56a..2a9f09e 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt
@@ -229,7 +229,8 @@ fun ChatMessage(
if (model.reactions.isNotEmpty()) {
MessageReactions(
- reactions = model.reactions,
+ emojis = model.reactions.map { it.emoji },
+ total = model.reactions.sumOf { it.authors.size },
modifier = Modifier.offset(y = 12.dp)
)
}
@@ -247,13 +248,14 @@ fun ChatMessage(
}
}
+/** The reaction chips under a bubble, shared by DMs and Channels. */
@Composable
-private fun MessageReactions(
- reactions: List,
+fun MessageReactions(
+ emojis: List,
+ total: Int,
modifier: Modifier = Modifier
) {
- val totalCount = reactions.sumOf { it.authors.size }
- val displayEmojis = reactions.take(3).map { it.emoji }
+ val displayEmojis = emojis.take(3)
Row(
modifier = modifier,
@@ -274,7 +276,7 @@ private fun MessageReactions(
}
}
}
- if (totalCount > 2) {
+ if (total > 2) {
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
@@ -282,7 +284,7 @@ private fun MessageReactions(
) {
Box(contentAlignment = Alignment.Center) {
Text(
- text = totalCount.toString(),
+ text = total.toString(),
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
)
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt
index 2a84d40..2965514 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt
@@ -650,7 +650,7 @@ private fun ReplyPreview(
}
@Composable
-private fun ReactionToolbar(
+fun ReactionToolbar(
onReaction: (String) -> Unit
) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt
new file mode 100644
index 0000000..e732054
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt
@@ -0,0 +1,365 @@
+package su.reya.coop.screens.communities
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.ScaffoldDefaults
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+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 coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import kotlinx.coroutines.flow.flowOf
+import org.jetbrains.compose.resources.painterResource
+import rust.nostr.sdk.PublicKey
+import su.reya.coop.LocalNavigator
+import su.reya.coop.LocalProfileCache
+import su.reya.coop.LocalSnackbarHostState
+import su.reya.coop.Profile
+import su.reya.coop.concord.ConcordMessage
+import su.reya.coop.dayLabel
+import su.reya.coop.sanitizeName
+import su.reya.coop.screens.chat.ChatInput
+import su.reya.coop.screens.chat.DateSeparator
+import su.reya.coop.screens.chat.MessageReactions
+import su.reya.coop.screens.chat.ReactionToolbar
+import su.reya.coop.shared.Avatar
+import su.reya.coop.short
+import su.reya.coop.timeLabel
+import su.reya.coop.viewmodel.ChannelScreenViewModel
+
+/**
+ * One Channel's history, laid out Discord-style: every message carries its author, because a Channel
+ * has many speakers rather than the two a DM has.
+ *
+ * The list is reversed, exactly as `ChatScreen` is, so the newest message sits at the bottom without
+ * any scroll bookkeeping — the item order below is bottom-to-top, and the day separator is placed
+ * after its group for that reason.
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ChannelScreen(viewModel: ChannelScreenViewModel) {
+ val navigator = LocalNavigator.current
+ val snackbarHostState = LocalSnackbarHostState.current
+ val listState = rememberLazyListState()
+
+ val channel by viewModel.channel.collectAsStateWithLifecycle()
+ val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
+
+ var text by remember { mutableStateOf("") }
+
+ /** The message whose action row is open, if any. */
+ var activeMessageId by remember { mutableStateOf(null) }
+
+ /** The message an Edit is being written for, if any. Its text lives in [text]. */
+ var editing by remember { mutableStateOf(null) }
+
+ val grouped by remember {
+ derivedStateOf {
+ viewModel.messages
+ .groupBy { it.dayLabel() }
+ .toList()
+ .reversed()
+ .map { (day, dayMessages) -> day to dayMessages.asReversed() }
+ }
+ }
+
+ Scaffold(
+ contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "#${channel?.name ?: "channel"}",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (channel?.hasKey == false) {
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = "No key",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ navigationIcon = {
+ IconButton(onClick = { navigator.goBack() }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ )
+ },
+ content = { innerPadding ->
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = innerPadding.calculateTopPadding()),
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = innerPadding.calculateBottomPadding())
+ ) {
+ if (viewModel.loading) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ } else if (viewModel.messages.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = "No messages yet",
+ style = MaterialTheme.typography.titleLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ // Nothing to add for a Channel we hold no key for: the bar at the
+ // bottom of this screen is already saying why it is empty.
+ if (channel?.hasKey != false) {
+ Text(
+ text = "Say something to start it off.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ state = listState,
+ reverseLayout = true,
+ contentPadding = PaddingValues(vertical = 8.dp),
+ ) {
+ grouped.forEach { (day, dayMessages) ->
+ items(dayMessages, key = { it.idHex }) { message ->
+ ChannelMessageRow(
+ message = message,
+ isMine = message.author == currentUser?.publicKey?.toHex(),
+ active = activeMessageId == message.idHex,
+ onDismissActions = { activeMessageId = null },
+ onLongPress = { activeMessageId = message.idHex },
+ onReaction = { emoji ->
+ viewModel.sendReaction(message, emoji)
+ activeMessageId = null
+ },
+ onEdit = {
+ editing = message
+ text = message.content
+ activeMessageId = null
+ },
+ )
+ }
+ item(key = "day:$day") { DateSeparator(day) }
+ }
+ }
+ }
+
+ // A Private Channel we hold no key for is listable but not writable, so the input
+ // is replaced by the reason rather than left to fail on send.
+ if (channel?.hasKey == false) {
+ Text(
+ text = "This channel was not part of the invite, so this device has no key " +
+ "for it.",
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ } else {
+ if (editing != null) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "Editing a message",
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ TextButton(onClick = { editing = null; text = "" }) { Text("Cancel") }
+ }
+ }
+
+ // No attachments and no dictation in v1, so the input shows neither: a button
+ // that does nothing would be worse than its absence.
+ ChatInput(
+ value = text,
+ onValueChange = { text = it },
+ onSend = {
+ val target = editing
+ if (target == null) viewModel.sendMessage(text)
+ else viewModel.editMessage(target.idHex, text)
+ editing = null
+ text = ""
+ },
+ )
+ }
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun ChannelMessageRow(
+ message: ConcordMessage,
+ isMine: Boolean,
+ active: Boolean,
+ onDismissActions: () -> Unit,
+ onLongPress: () -> Unit,
+ onReaction: (String) -> Unit,
+ onEdit: () -> Unit,
+) {
+ val (pubkey, profile) = rememberAuthor(message.author)
+ val name = profile?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
+ ?: pubkey?.short()
+ ?: message.author.take(8)
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ ) {
+ Avatar(picture = profile?.picture, description = name, size = 36.dp)
+ Spacer(modifier = Modifier.size(10.dp))
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .combinedClickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onDismissActions,
+ onLongClick = onLongPress,
+ )
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = if (isMine) "$name (you)" else name,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false),
+ )
+ Spacer(modifier = Modifier.size(6.dp))
+ Text(
+ text = message.timeLabel(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ if (message.edited) {
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(
+ text = "(edited)",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+ Text(
+ text = message.content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (message.reactions.isNotEmpty()) {
+ MessageReactions(
+ emojis = message.reactions.map { it.emoji },
+ total = message.reactions.sumOf { it.authors.size },
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ if (active) {
+ Column(modifier = Modifier.padding(top = 8.dp)) {
+ ReactionToolbar(onReaction = onReaction)
+ // Only the author may edit, so only they are offered it.
+ if (isMine) {
+ TextButton(onClick = onEdit) { Text("Edit") }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** The profile behind a message's author, fetched the ordinary Nostr way — see `Avatar`. */
+@Composable
+private fun rememberAuthor(authorHex: String): Pair {
+ val profileCache = LocalProfileCache.current
+ val pubkey = remember(authorHex) { runCatching { PublicKey.parse(authorHex) }.getOrNull() }
+ val profile by remember(pubkey) {
+ pubkey?.let { profileCache.getMetadata(it) } ?: flowOf(null)
+ }.collectAsStateWithLifecycle(null)
+
+ return pubkey to profile
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunitiesScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunitiesScreen.kt
new file mode 100644
index 0000000..73c0541
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunitiesScreen.kt
@@ -0,0 +1,185 @@
+package su.reya.coop.screens.communities
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import coop.composeapp.generated.resources.ic_plus
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.painterResource
+import su.reya.coop.LocalNavigator
+import su.reya.coop.LocalSnackbarHostState
+import su.reya.coop.Screen
+import su.reya.coop.displayName
+import su.reya.coop.viewmodel.ConcordViewModel
+
+/**
+ * Every Community this device has joined, plus any Direct Invite still waiting.
+ *
+ * There is no file upload, no message search and no per-Community settings page — v1 is the read
+ * path and sending, and the screen says so rather than pretending otherwise.
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun CommunitiesScreen(concordViewModel: ConcordViewModel) {
+ val navigator = LocalNavigator.current
+ val snackbarHostState = LocalSnackbarHostState.current
+ val scope = rememberCoroutineScope()
+
+ val communities by concordViewModel.communities.collectAsStateWithLifecycle()
+ val directInvites by concordViewModel.directInvites.collectAsStateWithLifecycle()
+ val isReady by concordViewModel.isReady.collectAsStateWithLifecycle()
+
+ val sorted = remember(communities) { communities.sortedBy { it.displayName().lowercase() } }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Communities",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ navigationIcon = {
+ IconButton(onClick = { navigator.goBack() }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
+ TooltipAnchorPosition.Above,
+ spacingBetweenTooltipAndAnchor = 8.dp,
+ ),
+ tooltip = {
+ PlainTooltip { Text("Join a community") }
+ },
+ state = rememberTooltipState(),
+ ) {
+ ExtendedFloatingActionButton(
+ onClick = { navigator.navigate(Screen.JoinCommunity()) },
+ expanded = false,
+ icon = {
+ Icon(
+ painter = painterResource(Res.drawable.ic_plus),
+ contentDescription = "Join a community"
+ )
+ },
+ text = { Text("Join") },
+ )
+ }
+ },
+ content = { innerPadding ->
+ when {
+ !isReady && sorted.isEmpty() -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ }
+
+ sorted.isEmpty() && directInvites.isEmpty() -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ CommunityEmptyState(
+ title = "No communities yet",
+ subtitle = "Join one with an invite link.",
+ )
+ }
+ }
+
+ else -> {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = innerPadding.calculateTopPadding()),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ items(directInvites, key = { it.communityId }) { invite ->
+ DirectInviteCard(
+ invite = invite,
+ onJoin = {
+ scope.launch {
+ // A Direct Invite already carries its bundle, so there is
+ // nothing to fetch — only to validate and accept.
+ val preview = concordViewModel.previewDirectInvite(invite)
+ val joined = concordViewModel.join(preview)
+ if (joined != null) {
+ navigator.navigate(
+ Screen.Community(joined.membership.communityId)
+ )
+ }
+ }
+ },
+ onDismiss = {
+ concordViewModel.dismissDirectInvite(invite.communityId)
+ },
+ )
+ }
+
+ items(sorted, key = { it.membership.communityId }) { state ->
+ CommunityRow(
+ state = state,
+ onClick = {
+ navigator.navigate(
+ Screen.Community(state.membership.communityId)
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+ },
+ )
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityComponents.kt
new file mode 100644
index 0000000..14fd74f
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityComponents.kt
@@ -0,0 +1,254 @@
+package su.reya.coop.screens.communities
+
+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.fillMaxSize
+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.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedListItem
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+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 coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_communities
+import coop.composeapp.generated.resources.ic_lock
+import org.jetbrains.compose.resources.painterResource
+import su.reya.coop.concord.CommunityInvite
+import su.reya.coop.concord.CommunityState
+import su.reya.coop.concord.ConcordChannel
+import su.reya.coop.displayName
+import su.reya.coop.shared.Avatar
+import su.reya.coop.unreadTotal
+
+/**
+ * Rows and small blocks shared by the Communities screens, mirroring `HomeScreen`'s `ChatRoom` and
+ * `ContactListItem`: `ListItem` for the top level, `SegmentedListItem` inside a group.
+ */
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun CommunityRow(state: CommunityState, onClick: () -> Unit) {
+ val unread = state.unreadTotal()
+
+ ListItem(
+ modifier = Modifier.clickable(onClick = onClick),
+ leadingContent = {
+ BadgedBox(
+ badge = {
+ if (unread > 0) {
+ Badge { Text(unread.toString()) }
+ }
+ }
+ ) {
+ // Concord icons are encrypted blobs v1 never fetches, so this is always the placeholder.
+ Avatar(picture = null, description = state.displayName())
+ }
+ },
+ headlineContent = {
+ Text(
+ text = state.displayName(),
+ style = MaterialTheme.typography.titleMediumEmphasized.copy(
+ fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = {
+ Text(
+ text = channelCountLabel(state.channels.size),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
+ )
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ChannelRow(channel: ConcordChannel, index: Int, total: Int, onClick: () -> Unit) {
+ val unread = channel.unreadCount
+
+ SegmentedListItem(
+ onClick = onClick,
+ shapes = ListItemDefaults.segmentedShapes(index = index, count = total),
+ leadingContent = {
+ BadgedBox(
+ badge = {
+ if (unread > 0) {
+ Badge { Text(unread.toString()) }
+ }
+ }
+ ) {
+ Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
+ if (channel.private) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_lock),
+ contentDescription = "Private",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.outline,
+ )
+ } else {
+ Text(
+ text = "#",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+ }
+ },
+ content = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = channel.name,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ // A Private Channel with no key is listable but unreadable: the row says so rather
+ // than opening an empty room and looking broken.
+ if (!channel.hasKey) {
+ Text(
+ text = "No key",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+ }
+ )
+}
+
+/**
+ * A Direct Invite (CORD-05 §6) waiting to be accepted. It arrived giftwrapped to this npub, so it is
+ * shown on the Communities list rather than in any chat.
+ */
+@Composable
+fun DirectInviteCard(invite: CommunityInvite, onJoin: () -> Unit, onDismiss: () -> Unit) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Invited to a community",
+ style = MaterialTheme.typography.labelMedium,
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(
+ text = invite.name ?: "Untitled community",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = Modifier.size(2.dp))
+ Text(
+ text = shortCommunityId(invite.communityId),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(onClick = onDismiss) { Text("Dismiss") }
+ Button(onClick = onJoin) { Text("Join") }
+ }
+ }
+ }
+}
+
+/** The app's empty-state convention: two centred lines, with the feature's mark above them. */
+@Composable
+fun CommunityEmptyState(title: String, subtitle: String) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_communities),
+ contentDescription = null,
+ modifier = Modifier.size(56.dp),
+ tint = MaterialTheme.colorScheme.outlineVariant,
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+}
+
+/**
+ * Says out loud what v1 does not do yet. Concord's authority is a signed roster every client folds
+ * (CORD-04), and this build folds none of it — a stale claim of moderation would be worse than none.
+ */
+@Composable
+fun BetaNotice() {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_lock),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.size(12.dp))
+ Text(
+ text = "Beta. Messages, channels and invites work. Roles, kicks and bans are not " +
+ "enforced by this build yet — only by the other clients in the community.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
+
+private fun channelCountLabel(count: Int): String =
+ if (count == 1) "1 channel" else "$count channels"
+
+/** A Community's identity is a hash, so show enough of it to tell two apart. */
+internal fun shortCommunityId(communityId: String): String =
+ if (communityId.length <= 16) communityId else communityId.take(16) + "..."
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityScreen.kt
new file mode 100644
index 0000000..c2895e4
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityScreen.kt
@@ -0,0 +1,171 @@
+package su.reya.coop.screens.communities
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import org.jetbrains.compose.resources.painterResource
+import su.reya.coop.LocalNavigator
+import su.reya.coop.LocalSnackbarHostState
+import su.reya.coop.Screen
+import su.reya.coop.displayName
+import su.reya.coop.viewmodel.ConcordViewModel
+
+/**
+ * One Community's Channels, split public and private the way the protocol splits them: a Public
+ * Channel's key derives from `community_root`, so anyone in the Community can read it, while a
+ * Private one is only listable to those an invite or a rekey handed a key to (CORD-03 §1).
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun CommunityScreen(communityId: String, concordViewModel: ConcordViewModel) {
+ val navigator = LocalNavigator.current
+ val snackbarHostState = LocalSnackbarHostState.current
+
+ val communities by concordViewModel.communities.collectAsStateWithLifecycle()
+ val state = remember(communities, communityId) {
+ communities.firstOrNull { it.membership.communityId == communityId }
+ }
+
+ if (state == null) {
+ // Reachable if the Community was dropped underneath this screen (a logout while it was open),
+ // so it keeps a way back rather than dead-ending here.
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Community",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ navigationIcon = {
+ IconButton(onClick = { navigator.goBack() }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ )
+ },
+ ) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "This community is not on this device.",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ return
+ }
+
+ val (privateChannels, publicChannels) = remember(state.channels) {
+ state.channels.partition { it.private }
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = state.displayName(),
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ navigationIcon = {
+ IconButton(onClick = { navigator.goBack() }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ )
+ },
+ content = { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = innerPadding.calculateTopPadding()),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ item { BetaNotice() }
+
+ itemsIndexed(publicChannels) { index, channel ->
+ ChannelRow(
+ channel = channel,
+ index = index,
+ total = publicChannels.size,
+ onClick = {
+ navigator.navigate(Screen.Channel(communityId, channel.idHex))
+ },
+ )
+ }
+
+ itemsIndexed(privateChannels) { index, channel ->
+ ChannelRow(
+ channel = channel,
+ index = index,
+ total = privateChannels.size,
+ onClick = {
+ navigator.navigate(Screen.Channel(communityId, channel.idHex))
+ },
+ )
+ }
+
+ if (state.channels.isEmpty()) {
+ item {
+ Text(
+ text = "No channels yet. They arrive with the community's metadata.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+ }
+ },
+ )
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/JoinCommunityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/JoinCommunityScreen.kt
new file mode 100644
index 0000000..994d9aa
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/JoinCommunityScreen.kt
@@ -0,0 +1,237 @@
+package su.reya.coop.screens.communities
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_arrow_back
+import coop.composeapp.generated.resources.ic_scanner
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.painterResource
+import su.reya.coop.LocalNavigator
+import su.reya.coop.LocalScanResult
+import su.reya.coop.LocalSnackbarHostState
+import su.reya.coop.Screen
+import su.reya.coop.concord.InvitePreview
+import su.reya.coop.viewmodel.ConcordViewModel
+
+/**
+ * Paste or scan an invite link, see what joining would mean, then join.
+ *
+ * The preview is deliberately a separate step: a link is fetched and decrypted, but nothing is
+ * subscribed and no presence is announced until the user accepts (CORD-05 §1), so backing out here
+ * leaves no trace on any relay.
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun JoinCommunityScreen(concordViewModel: ConcordViewModel, initialLink: String? = null) {
+ val navigator = LocalNavigator.current
+ val qrScanResult = LocalScanResult.current
+ val snackbarHostState = LocalSnackbarHostState.current
+ val scope = rememberCoroutineScope()
+
+ var link by remember(initialLink) { mutableStateOf(initialLink.orEmpty()) }
+ var preview by remember { mutableStateOf(null) }
+ var isBusy by remember { mutableStateOf(false) }
+
+ LaunchedEffect(qrScanResult.content) {
+ qrScanResult.content?.let { scanned ->
+ link = scanned
+ preview = null
+ qrScanResult.clear()
+ }
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Join a community",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ navigationIcon = {
+ IconButton(onClick = { navigator.goBack() }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_arrow_back),
+ contentDescription = "Back"
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_scanner),
+ contentDescription = "Scanner"
+ )
+ }
+ },
+ )
+ },
+ content = { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedTextField(
+ value = link,
+ onValueChange = {
+ link = it
+ // Any edit invalidates what was fetched for the old text.
+ preview = null
+ },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Invite link") },
+ placeholder = { Text("https://.../invite/...") },
+ maxLines = 4,
+ )
+
+ Button(
+ onClick = {
+ scope.launch {
+ isBusy = true
+ preview = concordViewModel.previewInvite(link)
+ isBusy = false
+ }
+ },
+ enabled = link.isNotBlank() && !isBusy,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Look it up")
+ }
+
+ preview?.let { shown ->
+ InvitePreviewCard(
+ preview = shown,
+ isBusy = isBusy,
+ onJoin = {
+ scope.launch {
+ isBusy = true
+ val joined = concordViewModel.join(shown)
+ isBusy = false
+ if (joined != null) {
+ // Replace this screen with the Community it just joined.
+ navigator.goBack()
+ navigator.navigate(
+ Screen.Community(joined.membership.communityId)
+ )
+ }
+ }
+ },
+ )
+ }
+ }
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun InvitePreviewCard(preview: InvitePreview, isBusy: Boolean, onJoin: () -> Unit) {
+ val invite = preview.invite
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = invite.name ?: "Untitled community",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ Text(
+ text = shortCommunityId(invite.communityId),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(
+ text = plural(invite.channels.size, "channel"),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ text = plural(preview.relays.size, "relay"),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+
+ // Everything that would stop this invite from being accepted, in plain English.
+ preview.problems.forEach { problem -> Refusal(problem) }
+ if (preview.expired) Refusal("This invite has expired.")
+ if (preview.alreadyJoined) {
+ Text(
+ text = "You are already in this community.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ Spacer(modifier = Modifier.size(8.dp))
+ Button(
+ onClick = onJoin,
+ enabled = preview.joinable && !isBusy,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (isBusy) {
+ LoadingIndicator(modifier = Modifier.size(20.dp))
+ } else {
+ Text(if (preview.alreadyJoined) "Rejoin" else "Join")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Refusal(message: String) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+}
+
+private fun plural(count: Int, noun: String): String =
+ if (count == 1) "1 $noun" else "$count ${noun}s"
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Community.kt b/shared/src/commonMain/kotlin/su/reya/coop/Community.kt
new file mode 100644
index 0000000..f815b54
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/Community.kt
@@ -0,0 +1,46 @@
+package su.reya.coop
+
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.minus
+import kotlinx.datetime.number
+import kotlinx.datetime.toLocalDateTime
+import su.reya.coop.concord.CommunityState
+import su.reya.coop.concord.ConcordMessage
+import kotlin.time.Clock
+
+/** Control metadata first, then the snapshot the invite carried, then a last-resort label. */
+fun CommunityState.displayName(): String {
+ val name = meta?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
+ ?: membership.name?.sanitizeName()?.takeIf { it.isNotBlank() }
+ return name ?: "Untitled community"
+}
+
+/** Every Channel's badge added up, for the Community row. */
+fun CommunityState.unreadTotal(): Int = channels.sumOf { it.unreadCount }
+
+/** `HH:mm`, local time. */
+fun ConcordMessage.timeLabel(): String {
+ val time = createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
+ val hour = time.hour.toString().padStart(2, '0')
+ val minute = time.minute.toString().padStart(2, '0')
+ return "$hour:$minute"
+}
+
+/** `Today` / `Yesterday` / `DD/MM/YY`, matching [formatAsGroup] for DMs. */
+fun ConcordMessage.dayLabel(): String {
+ val zone = TimeZone.currentSystemDefault()
+ val date = createdAt.toLocalDateTime(zone).date
+ val today = Clock.System.now().toLocalDateTime(zone).date
+
+ return when (date) {
+ today -> "Today"
+ today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
+ else -> {
+ val day = date.day.toString().padStart(2, '0')
+ val month = date.month.number.toString().padStart(2, '0')
+ val year = date.year.toString().takeLast(2)
+ "$day/$month/$year"
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCommunityList.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCommunityList.kt
new file mode 100644
index 0000000..5a27426
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCommunityList.kt
@@ -0,0 +1,129 @@
+package su.reya.coop.concord
+
+import kotlin.io.encoding.Base64
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * §8's 32-byte values are unpadded base64url rather than hex — the Community List is the one
+ * encrypted document CORD-01 lets choose its own encoding. Padding is tolerated on read.
+ */
+private val listBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
+
+/** One fragment's plaintext. `frags` and `seed` are read by writers, not here. */
+@Serializable
+internal data class CommunityListFragment(
+ val entries: List = emptyList(),
+ val tombstones: List = emptyList(),
+)
+
+@Serializable
+internal data class CommunityListEntry(
+ /** base64url, and case-significant: decoded, never case-folded. */
+ @SerialName("community_id") val communityId: String = "",
+ /** The freshest snapshot. `seed`, the backfill anchor, is not read here. */
+ val current: CommunityListSnapshot? = null,
+ /** Unix ms. Tiebreaks against the tombstone and between two entries. */
+ @SerialName("added_at") val addedAt: Long = 0L,
+)
+
+@Serializable
+internal data class CommunityListTombstone(
+ @SerialName("community_id") val communityId: String = "",
+ @SerialName("removed_at") val removedAt: Long = 0L,
+)
+
+/**
+ * The invite bundle's *membership* subset (CORD-05), in §8's base64url: never the icon, never the
+ * link fields.
+ */
+@Serializable
+internal data class CommunityListSnapshot(
+ val owner: String = "",
+ @SerialName("owner_salt") val ownerSalt: String = "",
+ @SerialName("community_root") val communityRoot: String = "",
+ @SerialName("root_epoch") val rootEpoch: ULong = 0u,
+ @SerialName("control_pk") val controlPk: String? = null,
+ val channels: List = emptyList(),
+ val relays: List = emptyList(),
+ val name: String? = null,
+)
+
+@Serializable
+internal data class CommunityListChannel(
+ val id: String = "",
+ val key: String = "",
+ val epoch: ULong = 0u,
+ val name: String? = null,
+)
+
+/**
+ * Unions fragments into one membership per Community (CORD-02 §8).
+ *
+ * Only the merges a *reader* needs: the newest entry per Community wins, and a Community whose
+ * tombstone is at least as new as its entry reads as left. `seed`, the canonical-bytes tiebreak and
+ * repacks exist to keep two writers byte-identical — a client that never republishes cannot flap.
+ */
+internal fun List.listedMemberships(): List {
+ val newest = mutableMapOf>()
+
+ for (entry in flatMap { it.entries }) {
+ val membership = entry.toMembership() ?: continue
+ val held = newest[membership.communityId]
+ if (held == null || entry.addedAt > held.first) {
+ newest[membership.communityId] = entry.addedAt to membership
+ }
+ }
+
+ val removed = mutableMapOf()
+ for (tombstone in flatMap { it.tombstones }) {
+ val id = tombstone.communityId.listId() ?: continue
+ removed[id] = maxOf(removed[id] ?: 0L, tombstone.removedAt)
+ }
+
+ return newest
+ .filter { (id, entry) -> (removed[id] ?: 0L) < entry.first }
+ .values.map { it.second }
+}
+
+/** One entry as a [Membership], reusing the invite bundle's own self-certification. */
+private fun CommunityListEntry.toMembership(): Membership? {
+ val id = communityId.listId() ?: return null
+ val invite = (current ?: return null).toInvite(id) ?: return null
+ if (invite.problems().isNotEmpty()) return null
+ return invite.toMembership(invite.relaySet())
+}
+
+/** A snapshot normalised to the hex [CommunityInvite] the invite path already validates. */
+private fun CommunityListSnapshot.toInvite(communityIdHex: String): CommunityInvite? {
+ val ownerHex = owner.listId() ?: return null
+ val saltHex = ownerSalt.listId() ?: return null
+ val rootHex = communityRoot.listId() ?: return null
+
+ // Strict: a malformed control key is a malformed entry, not a key to quietly drop null. Dropping
+ // it to null would leave the Control Plane unaddressable with nothing to show for it.
+ val controlHex = controlPk?.let { it.listId() ?: return null }
+
+ // Lenient by contrast: one unreadable Channel key costs that Channel, not the membership.
+ val granted = channels.mapNotNull { channel ->
+ val id = channel.id.listId() ?: return@mapNotNull null
+ val key = channel.key.listId() ?: return@mapNotNull null
+ InviteChannel(id = id, key = key, epoch = channel.epoch, name = channel.name)
+ }
+
+ return CommunityInvite(
+ communityId = communityIdHex,
+ owner = ownerHex,
+ ownerSalt = saltHex,
+ communityRoot = rootHex,
+ rootEpoch = rootEpoch,
+ controlPk = controlHex,
+ channels = granted,
+ relays = relays,
+ name = name,
+ )
+}
+
+/** §8's unpadded base64url for a 32-byte value, or null when it is not one. */
+private fun String.listId(): String? =
+ runCatching { listBase64.decode(this) }.getOrNull()?.takeIf { it.size == 32 }?.toHex()
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt
new file mode 100644
index 0000000..7b3a21a
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt
@@ -0,0 +1,92 @@
+package su.reya.coop.concord
+
+import kotlinx.serialization.decodeFromString
+import rust.nostr.sdk.UnsignedEvent
+import kotlin.time.Instant
+
+object ConcordControl {
+ fun editionOf(rumor: UnsignedEvent): ControlEdition? {
+ if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
+
+ val tags = rumor.tags().toVec()
+ val vsk = tags.firstOrNull { it.kind() == ConcordTag.VSK }?.content()?.toIntOrNull() ?: return null
+ if (vsk == ConcordVsk.DISSOLVED) return null
+
+ val eidHex = tags.firstOrNull { it.kind() == ConcordTag.EID }?.content() ?: return null
+ if (eidHex.hex32() == null) return null
+
+ val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
+ if (version == 0uL) return null
+
+ val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
+ if (prev != null && prev.hex32() == null) return null
+
+ return ControlEdition(
+ vsk = vsk,
+ eidHex = eidHex.lowercase(),
+ version = version,
+ prevHashHex = prev?.lowercase(),
+ content = rumor.content(),
+ actorHex = rumor.author().toHex(),
+ rumorIdHex = rumor.id()?.toHex() ?: return null,
+ createdAt = Instant.fromEpochSeconds(rumor.createdAt().asSecs().toLong()),
+ )
+ }
+
+ fun fold(editions: List, communityIdHex: String): ControlFold {
+ var meta: CommunityMeta? = null
+ val channels = mutableMapOf()
+
+ editions.groupBy { it.vsk to it.eidHex }.forEach { (entity, group) ->
+ val (vsk, eidHex) = entity
+ if (vsk != ConcordVsk.METADATA && vsk != ConcordVsk.CHANNEL) return@forEach
+ // The metadata entity *is* the Community, so its coordinate must be the community_id;
+ // an edition naming anything else is not this Community's metadata.
+ if (vsk == ConcordVsk.METADATA && eidHex != communityIdHex) return@forEach
+
+ val winner = winner(group) ?: return@forEach
+ when (vsk) {
+ ConcordVsk.METADATA -> meta = decode(winner.content)
+ else -> decode(winner.content)?.let { channels[eidHex] = it }
+ }
+ }
+
+ return ControlFold(meta = meta, channels = channels)
+ }
+
+ private fun winner(group: List): ControlEdition? {
+ val byVersion = group
+ .groupBy { it.version }
+ .mapValues { (_, candidates) -> candidates.minByOrNull { it.rumorIdHex } ?: return null }
+
+ val eid = byVersion.values.firstOrNull()?.eidHex?.hex32() ?: return null
+
+ for (top in byVersion.keys.sortedDescending()) {
+ var prev: ByteArray? = null
+ var candidate: ControlEdition? = null
+ var intact = true
+
+ for (version in 1uL..top) {
+ val edition = byVersion[version]
+ if (edition == null) {
+ intact = false
+ break
+ }
+ // The first edition has no predecessor; every later one must chain to the last.
+ val expected = if (version == 1uL) null else prev?.toHex()
+ if (edition.prevHashHex != expected) {
+ intact = false
+ break
+ }
+ prev = editionHash(eid, version, prev, edition.content.encodeToByteArray())
+ candidate = edition
+ }
+
+ if (intact) return candidate
+ }
+ return null
+ }
+
+ private inline fun decode(content: String): T? =
+ runCatching { concordJson.decodeFromString(content) }.getOrNull()
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt
new file mode 100644
index 0000000..a72ab7f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt
@@ -0,0 +1,157 @@
+package su.reya.coop.concord
+
+import okio.Buffer
+import okio.ByteString
+import okio.ByteString.Companion.decodeHex
+import okio.ByteString.Companion.toByteString
+import rust.nostr.sdk.Keys
+import rust.nostr.sdk.PublicKey
+import rust.nostr.sdk.SecretKey
+
+fun hkdfSha256(
+ ikm: ByteArray,
+ info: ByteArray,
+ salt: ByteArray = ByteArray(0),
+ length: Int = 32,
+): ByteArray {
+ require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" }
+
+ val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
+ val prk = ikm.toByteString().hmacSha256(saltKey)
+
+ val out = Buffer()
+ var t = ByteString.EMPTY
+ var counter = 1
+
+ while (out.size < length) {
+ t = Buffer()
+ .write(t)
+ .write(info)
+ .writeByte(counter)
+ .readByteString()
+ .hmacSha256(prk)
+ out.write(t)
+ counter++
+ }
+
+ return out.readByteArray(length.toLong())
+}
+
+fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
+ require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" }
+ return Buffer().apply {
+ writeUtf8(label)
+ writeByte(0)
+ write(id)
+ if (epoch != null) writeLong(epoch.toLong())
+ }.readByteArray()
+}
+
+/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */
+data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
+
+fun groupSeed(
+ label: String,
+ secret: ByteArray,
+ id: ByteArray,
+ epoch: ULong? = null,
+ isValid: (ByteArray) -> Boolean = ::isValidScalar,
+): ByteArray {
+ val base = hkdfInfo(label, id, epoch)
+ var counter = -1
+
+ while (counter <= 255) {
+ val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
+ val seed = hkdfSha256(secret, info)
+ if (isValid(seed)) return seed
+ counter++
+ }
+
+ error("Concord group_key: scalar_normalize exhausted for label $label")
+}
+
+fun isValidScalar(seed: ByteArray): Boolean {
+ if (seed.size != 32) return false
+ var anyNonZero = false
+
+ for (byte in seed) {
+ if (byte != 0.toByte()) {
+ anyNonZero = true
+ break
+ }
+ }
+
+ if (!anyNonZero) return false
+ for (i in 0 until 32) {
+ val candidate = seed[i].toInt() and 0xff
+ val order = SECP256K1_ORDER[i].toInt() and 0xff
+ if (candidate != order) return candidate < order
+ }
+
+ return false
+}
+
+private val SECP256K1_ORDER =
+ "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
+
+fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
+ val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
+ return GroupKey(secretKey, Keys(secretKey).publicKey())
+}
+
+fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
+ require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" }
+ require(ownerSalt.size == 32) { "owner_salt must be 32 bytes, was ${ownerSalt.size}" }
+ return Buffer().apply {
+ writeUtf8(ConcordLabel.COMMUNITY)
+ write(ownerXonly)
+ write(ownerSalt)
+ }.readByteString().sha256().toByteArray()
+}
+
+fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
+ require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
+ return Buffer().apply {
+ writeUtf8(ConcordLabel.EPOCH_KEY_COMMITMENT)
+ writeLong(prevEpoch.toLong())
+ write(prevKey)
+ }.readByteString().sha256().toByteArray()
+}
+
+fun editionHash(entityId: ByteArray, version: ULong, prev: ByteArray?, content: ByteArray): ByteArray {
+ require(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" }
+ require(prev == null || prev.size == 32) { "prev must be 32 bytes" }
+ val label = ConcordLabel.EDITION_HASH.encodeToByteArray()
+ return Buffer().apply {
+ writeLong(label.size.toLong())
+ write(label)
+ write(entityId)
+ writeLong(version.toLong())
+ if (prev != null) {
+ writeByte(1)
+ write(prev)
+ } else {
+ writeByte(0)
+ write(ByteArray(32))
+ }
+ writeLong(content.size.toLong())
+ write(content)
+ }.readByteString().sha256().toByteArray()
+}
+
+/** 64 lowercase hex chars, the encoding CORD-01 mandates for every 32-byte value on the wire. */
+internal fun ByteArray.toHex(): String = toByteString().hex()
+
+/** Inverse of [toHex]. Throws on odd length or a non-hex character. */
+internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray()
+
+/** Inverse of [toHex], returning null instead of throwing — for reading untrusted input. */
+internal fun String.hexToBytesOrNull(): ByteArray? = runCatching { hexToBytes() }.getOrNull()
+
+/**
+ * A 32-byte value read from hex, or null when it is not exactly 32 bytes.
+ *
+ * The length check is the point: most of Concord's ids and keys are 32 bytes, and a short or long
+ * value must be rejected before it reaches a derivation that assumes a fixed width.
+ */
+internal fun String.hex32(): ByteArray? = if (length == 64) hexToBytesOrNull() else null
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt
new file mode 100644
index 0000000..d41aede
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt
@@ -0,0 +1,193 @@
+package su.reya.coop.concord
+
+import kotlin.io.encoding.Base64
+import rust.nostr.sdk.Nip19Coordinate
+import rust.nostr.sdk.RelayUrl
+import rust.nostr.sdk.nip44Decrypt
+
+/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
+internal object ConcordRelayDictionary {
+ val STOCK = listOf(
+ "wss://jskitty.com/nostr",
+ "wss://asia.vectorapp.io/nostr",
+ "wss://relay.ditto.pub",
+ "wss://relay.dreamith.to",
+ )
+}
+
+/** The link's secret half: `[version][flags][relays?][token:16]`, base64url without padding. */
+data class InviteFragment(
+ val version: Int,
+ val relays: List,
+ /** The 16-byte unlock token, hex. Derives exactly one thing: the bundle key. */
+ val tokenHex: String,
+)
+
+/** A parsed invite link: the public locator plus the secret fragment. */
+data class ParsedInvite(
+ val signerHex: String,
+ val identifier: String,
+ /** Relays the naddr itself names. Usually empty; the compact form travels in the fragment. */
+ val naddrRelays: List,
+ val fragment: InviteFragment,
+)
+
+private const val FRAGMENT_VERSION = 4
+
+/** The one flag v1 knows: the stock set is in use, so zero relay bytes follow. */
+private const val FLAG_STOCK_RELAYS = 1
+private const val TOKEN_BYTES = 16
+
+/** CORD-05 §3: the fragment only needs to *find* the bundle; the bundle carries the real set. */
+private const val MAX_BOOTSTRAP_RELAYS = 3
+
+/** CORD-05 §1: the spec's reference ceiling on a bundle's Channel list. */
+private const val MAX_INVITE_CHANNELS = 256
+
+/** CORD-02 §6: five stable relays is the recommendation, not a rule; a client may truncate. */
+private const val MAX_COMMUNITY_RELAYS = 5
+
+/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
+private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
+
+fun parseInviteLink(text: String): ParsedInvite? {
+ val trimmed = text.trim()
+ val hash = trimmed.indexOf('#')
+ if (hash <= 0 || hash == trimmed.lastIndex) return null
+
+ val naddr = trimmed.substring(0, hash).substringAfterLast('/')
+ val fragment = decodeInviteFragment(trimmed.substring(hash + 1)) ?: return null
+
+ val coordinate = runCatching { Nip19Coordinate.fromBech32(naddr) }.getOrNull() ?: return null
+ if (coordinate.coordinate().kind().asU16() != ConcordKind.INVITE_BUNDLE.toUShort()) return null
+
+ return ParsedInvite(
+ signerHex = coordinate.coordinate().publicKey().toHex(),
+ identifier = coordinate.coordinate().identifier(),
+ naddrRelays = coordinate.relays(),
+ fragment = fragment,
+ )
+}
+
+fun decodeInviteFragment(fragment: String): InviteFragment? {
+ val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
+ // Two header bytes plus the token at minimum.
+ if (bytes.size < 2 + TOKEN_BYTES) return null
+
+ val reader = FragmentReader(bytes)
+ if (reader.byte() != FRAGMENT_VERSION) return null
+
+ val flags = reader.byte() ?: return null
+
+ val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
+ ConcordRelayDictionary.STOCK
+ } else {
+ val count = reader.byte() ?: return null
+ buildList {
+ repeat(count) {
+ val lead = reader.byte() ?: return null
+ val relay = when (lead) {
+ 0 -> reader.slice(reader.byte() ?: return null)?.let { "wss://$it" }
+ 255 -> reader.slice(reader.byte() ?: return null)
+ else -> ConcordRelayDictionary.STOCK.getOrNull(lead - 1)
+ } ?: return null
+ add(relay)
+ }
+ }.take(MAX_BOOTSTRAP_RELAYS)
+ }
+
+ // Whatever is left must be exactly the token: a short one silently weakens the link.
+ val token = bytes.copyOfRange(reader.index, bytes.size)
+ if (token.size != TOKEN_BYTES) return null
+
+ return InviteFragment(
+ version = FRAGMENT_VERSION,
+ relays = relays,
+ tokenHex = token.toHex(),
+ )
+}
+
+fun inviteBundleKey(tokenHex: String): GroupKey? =
+ tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
+
+fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
+ val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
+ ?: return null
+ return runCatching { concordJson.decodeFromString(plaintext) }.getOrNull()
+}
+
+fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
+
+fun CommunityInvite.problems(): List {
+ val problems = mutableListOf()
+
+ val ownerBytes = owner.hex32()
+ val saltBytes = ownerSalt.hex32()
+ val rootBytes = communityRoot.hex32()
+
+ if (ownerBytes == null) problems += "The invite's owner key is malformed"
+ if (saltBytes == null) problems += "The invite's owner salt is malformed"
+ if (rootBytes == null) problems += "The invite's community key is malformed"
+
+ if (ownerBytes != null && saltBytes != null) {
+ if (communityId(ownerBytes, saltBytes).toHex() != communityId.lowercase()) {
+ problems += "The invite does not prove its community id"
+ }
+ }
+
+ if (communityId.hex32() == null) problems += "The invite's community id is malformed"
+ if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
+
+ if (channels.size > MAX_INVITE_CHANNELS) {
+ problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
+ }
+
+ channels.forEachIndexed { index, channel ->
+ if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
+ if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
+ }
+
+ return problems
+}
+
+fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
+
+fun CommunityInvite.relaySet(fallback: List = emptyList()): List {
+ val source = relays.ifEmpty { fallback }
+ return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
+}
+
+fun CommunityInvite.toMembership(relays: List): Membership = Membership(
+ communityId = communityId.lowercase(),
+ owner = owner.lowercase(),
+ ownerSalt = ownerSalt.lowercase(),
+ communityRoot = communityRoot.lowercase(),
+ rootEpoch = rootEpoch,
+ controlPk = controlPk?.lowercase(),
+ controlRoot = null,
+ relays = relays,
+ name = name,
+ channels = channels.map { channel ->
+ StoredChannelKey(
+ id = channel.id.lowercase(),
+ key = channel.key.lowercase(),
+ epoch = channel.epoch,
+ name = channel.name,
+ private = !channel.key.equals(communityRoot, ignoreCase = true),
+ )
+ },
+)
+
+private class FragmentReader(private val bytes: ByteArray) {
+ var index = 0
+ private set
+
+ fun byte(): Int? = if (index < bytes.size) bytes[index++].toInt() and 0xff else null
+
+ fun slice(length: Int): String? {
+ if (length < 0 || index + length > bytes.size) return null
+ val value = bytes.decodeToString(index, index + length)
+ index += length
+ return value
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt
new file mode 100644
index 0000000..0fcfda7
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt
@@ -0,0 +1,263 @@
+package su.reya.coop.concord
+
+object ConcordKind {
+ /** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
+ const val WRAP = 1059
+
+ /** Ephemeral wrap for kinds that must never be stored (typing, voice presence). */
+ const val EPHEMERAL_WRAP = 21059
+
+ /**
+ * Sealed content for everything that must stay secret: Chat, Guestbook, rekey blobs
+ * and Direct Invites. CORD-02 §5 makes this choice normative, never stylistic.
+ */
+ const val SEAL = 20013
+
+ /**
+ * Plaintext seal. **Control plane only** (CORD-02 §5) — the roster is public by design
+ * so that membership, and therefore authority, is verifiable by anyone.
+ */
+ const val PLAINTEXT_SEAL = 20014
+
+ /** Channel message (NIP-C7 shape). Chat plane. */
+ const val MESSAGE = 9
+
+ /** Threaded reply (NIP-22 shape). Chat plane. */
+ const val REPLY = 1111
+
+ /** Reaction (NIP-25 shape). Chat plane. */
+ const val REACTION = 7
+
+ /** Delete (NIP-09 shape). Chat plane. */
+ const val DELETE = 5
+
+ /** Disappearing-message timer notice (CORD-08 §4). Chat plane. */
+ const val TIMER_NOTICE = 1740
+
+ /** Edit. Chat plane. */
+ const val EDIT = 3302
+
+ /** Rekey blobs (CORD-06). Rekey addresses. */
+ const val REKEY = 3303
+
+ /** Join / Leave. Guestbook plane. */
+ const val JOIN_LEAVE = 3306
+
+ /** Control edition — sub-kinded by [ConcordVsk]. Control plane. */
+ const val CONTROL_EDITION = 3308
+
+ /** Kick. Guestbook plane. */
+ const val KICK = 3309
+
+ /** WebXDC peer signal. Chat plane. */
+ const val WEBXDC_SIGNAL = 3310
+
+ /** Guestbook snapshot, chunked, refounder-signed. Guestbook plane. */
+ const val GUESTBOOK_SNAPSHOT = 3312
+
+ /** Typing indicator. Chat plane, ephemeral. */
+ const val TYPING = 23311
+
+ /** Voice presence (CORD-07). Chat plane, ephemeral. */
+ const val VOICE_PRESENCE = 23313
+
+ /** Direct Invite — giftwrapped straight to an npub. Rides a standard wrap, not a stream. */
+ const val DIRECT_INVITE = 3313
+
+ /** Public invite bundle. Addressable, signed by its per-link keypair at an empty `d`. */
+ const val INVITE_BUNDLE = 33301
+
+ /** Community List — one addressable event per fragment, NIP-44 to self. */
+ const val COMMUNITY_LIST = 33302
+
+ /**
+ * The single-event Community List [COMMUNITY_LIST] superseded. Retired and never written;
+ * queried only so "no List exists" can be told apart from "a client still speaks the old shape".
+ */
+ const val RETIRED_COMMUNITY_LIST = 13302
+
+ /** Invite List — replaceable, NIP-44 to self. */
+ const val INVITE_LIST = 13303
+}
+
+/** Control edition sub-kinds, the `vsk` tag on [ConcordKind.CONTROL_EDITION] (CORD-02 Appendix A). */
+object ConcordVsk {
+ /** Community metadata. */
+ const val METADATA = 0
+
+ /** Role. */
+ const val ROLE = 1
+
+ /** Channel metadata — the only other edition v1 folds. */
+ const val CHANNEL = 2
+
+ /** Grant. */
+ const val GRANT = 3
+
+ /** Banlist. */
+ const val BANLIST = 4
+
+ /** Reserved for role ordering. */
+ const val RESERVED_ROLE_ORDER = 5
+
+ /** Claimed by the invite bundle's live marker. */
+ const val INVITE_LIVE = 6
+
+ /** Retired (was the v1 owner attestation). */
+ const val RETIRED_OWNER_ATTESTATION = 7
+
+ /** Invite-link registry. */
+ const val INVITE_REGISTRY = 8
+
+ /** Claimed by the invite bundle's revocation tombstone. */
+ const val INVITE_TOMBSTONE = 9
+
+ /** Dissolved tombstone — chainless, exempt from version discipline. */
+ const val DISSOLVED = 10
+
+ /** Pin List. */
+ const val PIN_LIST = 11
+}
+
+object ConcordPermission {
+ const val MANAGE_ROLES = 1 shl 0
+ const val MANAGE_CHANNELS = 1 shl 1
+ const val MANAGE_METADATA = 1 shl 2
+ const val KICK = 1 shl 3
+ const val BAN = 1 shl 4
+ const val MANAGE_MESSAGES = 1 shl 5
+ const val CREATE_INVITE = 1 shl 6
+
+ /** Retired (was `MANAGE_INVITES`). Never grant it. */
+ const val RETIRED_MANAGE_INVITES = 1 shl 7
+
+ const val VIEW_AUDIT_LOG = 1 shl 8
+ const val MENTION_EVERYONE = 1 shl 9
+
+ const val PIN_MESSAGES = 1 shl 11
+
+ /**
+ * Staff = anyone holding a staff bit, plus the owner. Staff are the ones who hold
+ * `control_root` and can therefore write to the Control plane.
+ */
+ const val STAFF = MANAGE_ROLES or MANAGE_CHANNELS or MANAGE_METADATA or
+ BAN or CREATE_INVITE or PIN_MESSAGES
+}
+
+object ConcordLabel {
+ /** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
+ const val CHANNEL = "concord/channel"
+
+ /** Control Plane **read** key. `secret` = `community_root`. */
+ const val CONTROL = "concord/control"
+
+ /** Control Plane signer, held by staff only. `secret` = `control_root`. */
+ const val CONTROL_SIGNER = "concord/control-signer"
+
+ /** Guestbook Plane group key. `secret` = `community_root`. */
+ const val GUESTBOOK = "concord/guestbook"
+
+ /** Dissolution tombstone address. `secret` = `community_id`, id = 32 zero bytes, no epoch. */
+ const val DISSOLVED = "concord/dissolved"
+
+ /** A channel rekey address. `secret` = the prior `community_root`. */
+ const val REKEY_PSEUDONYM = "concord/rekey-pseudonym"
+
+ /** A base (community-wide) rekey address. `secret` = the prior `community_root`. */
+ const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym"
+
+ /** A rekey blob locator. `secret` = `rotator_xonly ‖ recipient_xonly`. */
+ const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym"
+
+ /** SFU room keypair (CORD-07). */
+ const val VOICE_SIGNER = "concord/voice-signer"
+
+ /** 32-byte call media key (CORD-07). */
+ const val VOICE_MEDIA = "concord/voice-media"
+
+ /** Per-sender frame key (CORD-07). id = `sha256(identity)`, no epoch. */
+ const val VOICE_SENDER = "concord/voice-sender"
+
+ /** A member's Grant coordinate. no epoch. */
+ const val GRANT = "concord/grant"
+
+ /** Banlist coordinate. no epoch. */
+ const val BANLIST = "concord/banlist"
+
+ /** A Channel's Pin List coordinate. no epoch. */
+ const val PINS = "concord/pins"
+
+ /** A creator's invite-link registry coordinate. no epoch. */
+ const val INVITE_LINKS = "concord/invite-links"
+
+ /** Public-invite decrypt key. `secret` = the link's unlock token, no epoch. */
+ const val INVITE_KEY = "concord/invite-key"
+
+ /**
+ * Prefix for the `community_id` commitment (CORD-02 A.4). Not an HKDF label — it is
+ * hashed directly, with no `0x00` separator and no length prefix.
+ */
+ const val COMMUNITY = "concord/community"
+
+ /** Prefix for `prevcommit` (CORD-02 A.8). Also hashed directly, not through HKDF. */
+ const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment"
+
+ /**
+ * Label for `edition_hash` (CORD-02 A.8).
+ *
+ * Deliberately **not** under the `concord/` prefix: the spec pins the reference
+ * implementation's own `vector` label here. Reproduce it exactly.
+ */
+ const val EDITION_HASH = "vector-community/v1/edition"
+}
+
+/** Tag names (CORD-01, CORD-02 §5 and the per-kind sections). */
+object ConcordTag {
+ /** `["ms", "<0..999>"]` — sub-second ordering. True time = `created_at * 1000 + ms`. */
+ const val MS = "ms"
+
+ /** `["p", ""]` — the one tag a stream wrap carries (NIP-59 reversed). */
+ const val P = "p"
+
+ /** `["channel", ""]` — MUST also be committed inside the author-signed rumor. */
+ const val CHANNEL = "channel"
+
+ /** `["epoch", ""]` — MUST also be committed inside the author-signed rumor. */
+ const val EPOCH = "epoch"
+
+ /** `["q", "", "", ""]` — NIP-C7 inline quote. */
+ const val QUOTE = "q"
+
+ /** `["e", ""]` — the message a reaction, edit or delete targets (CORD-03 §2.3–2.5). */
+ const val E = "e"
+
+ /** `["vsk", ""]` — Control edition entity type. */
+ const val VSK = "vsk"
+
+ /** `["eid", ""]` — stable coordinate of a Control edition's entity. */
+ const val EID = "eid"
+
+ /** `["ev", ""]` — this edition's version, climbing from 1. */
+ const val EV = "ev"
+
+ /** `["ep", ""]` — previous edition hash. Absent on the first edition. */
+ const val EP = "ep"
+
+ /** `["vac", "", "", ""]` — authority citation. Absent when the owner acts. */
+ const val VAC = "vac"
+
+ /** `["expiration", ""]` — NIP-40. MUST be on both wrap and rumor, same value. */
+ const val EXPIRATION = "expiration"
+
+ /** `["d", ""]` / `["d", ""]` — addressable coordinate. */
+ const val D = "d"
+
+ /** `["snap", "", "", ""]` — Guestbook snapshot chunk. */
+ const val SNAP = "snap"
+
+ /** `["invite", "", ""]` — optional invite attribution on a join. */
+ const val INVITE = "invite"
+
+ /** `["k", ""]` — the target's kind on a reaction, the invite hint on a `3313` wrap. */
+ const val K = "k"
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt
new file mode 100644
index 0000000..9347141
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt
@@ -0,0 +1,742 @@
+package su.reya.coop.concord
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import rust.nostr.sdk.AckPolicy
+import rust.nostr.sdk.Event
+import rust.nostr.sdk.EventId
+import rust.nostr.sdk.Filter
+import rust.nostr.sdk.Kind
+import rust.nostr.sdk.PublicKey
+import rust.nostr.sdk.RelayUrl
+import rust.nostr.sdk.ReqTarget
+import rust.nostr.sdk.SendEventTarget
+import rust.nostr.sdk.Tag
+import rust.nostr.sdk.UnsignedEvent
+import su.reya.coop.AppStorage
+import su.reya.coop.nostr.Nostr
+import kotlin.concurrent.Volatile
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+
+class ConcordManager(private val nostr: Nostr) {
+ @Volatile
+ private var planes: Map = emptyMap()
+
+ /** Latest Control fold per community id. Replaced wholesale; see [planes]. */
+ @Volatile
+ private var folds: Map = emptyMap()
+
+ /**
+ * Unread badges by Channel id. Replaced wholesale; see [planes]. In memory only, mirroring
+ * `Room.unreadCount` in the chat layer, so a restart begins with every badge cleared.
+ */
+ @Volatile
+ private var unread: Map = emptyMap()
+
+ /**
+ * Bumped whenever a plane rumor is cached or a message is sent, so a screen showing a Channel
+ * has something to re-query on. A counter rather than a set of changed scopes, because two
+ * messages to the same Channel must both be observable.
+ */
+ private val _revision = MutableStateFlow(0L)
+ val revision: StateFlow = _revision.asStateFlow()
+
+ private val _memberships = MutableStateFlow>(emptyList())
+ val memberships: StateFlow> = _memberships.asStateFlow()
+
+ private val _communities = MutableStateFlow>(emptyList())
+ val communities: StateFlow> = _communities.asStateFlow()
+
+ /** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
+ private val _directInvites = MutableStateFlow>(emptyList())
+ val directInvites: StateFlow> = _directInvites.asStateFlow()
+
+ /**
+ * True once [restore] has run. An empty [memberships] means the same thing before and after it,
+ * so a screen needs this to tell "joined nothing" from "not loaded yet".
+ */
+ private val _restored = MutableStateFlow(false)
+ val restored: StateFlow = _restored.asStateFlow()
+
+ private var store: ConcordStore? = null
+
+ /**
+ * Hands over the encrypted storage [Nostr] cannot reach on its own.
+ *
+ * [Nostr] is a Context-free singleton, so the Android layer passes [AppStorage] in through
+ * [Nostr.init]. Until this is called nothing can be persisted or restored, and the manager
+ * simply routes nothing.
+ */
+ internal fun attach(storage: AppStorage) {
+ store = ConcordStore(storage, nostr)
+ }
+
+ /** True when a wrap's author is one of our planes' stream keys. */
+ fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
+
+ /**
+ * Handles one stream wrap from a plane we hold a key for.
+ *
+ * [PlaneKey.unwrap] runs every check a reader must make and returns null on any failure, so a
+ * forged, unverifiable, mis-bound or undecryptable wrap is dropped here without ever being
+ * rendered. What survives is stored under its logical scope and, for Control, re-folded.
+ */
+ suspend fun handlePlaneEvent(event: Event) {
+ val plane = planes[event.author().toHex()] ?: return
+ val rumor = plane.key.unwrap(event) ?: return
+ val store = store ?: return
+
+ store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
+ _revision.update { it + 1 }
+
+ // A message from someone else is the only thing a badge counts. Our own wraps come back
+ // through the same subscription and so does a re-fetch, so the check is on the rumor's
+ // author rather than on the arrival.
+ if (plane.role == PlaneRole.Channel) countUnread(plane.scopeIdHex, rumor)
+
+ // Control carries consensus state, so every edition changes what we can read: a new
+ // Channel means a new plane address, and therefore a new subscription.
+ if (plane.role == PlaneRole.Control) refreshControl(plane.communityIdHex)
+ }
+
+ /**
+ * Intercepts rumors that arrive through the ordinary NIP-17 giftwrap pipeline and belong to
+ * Concord instead.
+ *
+ * Returns true when Concord consumed the rumor. A Direct Invite (kind `3313`) rides a *standard*
+ * NIP-59 wrap, so it unwraps fine — but it is a key handoff, not a message, and letting it
+ * through would have the chat layer build a room out of it. Returning true here keeps all
+ * Concord kind knowledge inside this package and leaves the chat code untouched.
+ */
+ fun onInboxRumor(rumor: UnsignedEvent): Boolean {
+ if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
+
+ val invite =
+ runCatching { concordJson.decodeFromString(rumor.content()) }.getOrNull()
+ if (invite == null) {
+ println("Concord: a direct invite arrived but its bundle could not be read")
+ return true
+ }
+ if (invite.problems().isNotEmpty()) {
+ println("Concord: refusing a direct invite that does not prove its community id")
+ return true
+ }
+
+ _directInvites.update { current ->
+ if (current.any {
+ it.communityId.equals(
+ invite.communityId,
+ ignoreCase = true
+ )
+ }) current
+ else current + invite
+ }
+ return true
+ }
+
+ /** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
+ suspend fun restore() {
+ val store = store ?: return
+ val memberships = store.loadMemberships()
+ _memberships.value = memberships
+
+ // Fold what is already on disk so Channels and metadata render immediately. Live editions
+ // arrive later through handlePlaneEvent and re-fold.
+ folds = memberships.associate { membership ->
+ membership.communityId to ConcordControl.fold(
+ editions = store.cachedRumors(membership.communityId)
+ .mapNotNull { ConcordControl.editionOf(it) },
+ communityIdHex = membership.communityId,
+ )
+ }
+ refreshPlanes()
+ _restored.value = true
+ }
+
+ /**
+ * Drops every membership and everything derived from it.
+ *
+ * Membership keys are identity-scoped — holding them *is* being in the Community (CORD-02 §2) —
+ * so signing out has to take them with it, or the next identity on this device holds a seat it
+ * never took. The cached rumors go with `Nostr.prune()`; the subscriptions are dropped here,
+ * since nothing else would.
+ */
+ suspend fun reset() {
+ _memberships.value.forEach { membership ->
+ nostr.client?.unsubscribe(subscriptionId(membership.communityId))
+ }
+
+ store?.clearMemberships()
+
+ _memberships.value = emptyList()
+ _directInvites.value = emptyList()
+
+ folds = emptyMap()
+ unread = emptyMap()
+
+ refreshPlanes()
+ }
+
+ /** Network half of startup: adopt what other clients hold, then connect and subscribe. */
+ suspend fun sync() {
+ adoptCommunityList()
+
+ for (membership in _memberships.value) {
+ try {
+ subscribeCommunity(membership)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: could not subscribe to ${membership.communityId}: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Adopts the memberships this identity holds on other clients (CORD-02 §8).
+ *
+ * The Community List is the only way a Community joined elsewhere can appear here: an invite
+ * hands keys to one device, while the List is the member's own self-encrypted vault on their
+ * own relays. Add-only — a tombstone for a Community we do not hold is honoured, but nothing
+ * this device joined is dropped, because Coop never writes the List and so cannot tell which
+ * of the two states is newer.
+ */
+ private suspend fun adoptCommunityList() {
+ val client = nostr.client
+ if (client == null) {
+ println("Concord: Community List skipped — no client yet")
+ return
+ }
+ val store = store
+ if (store == null) {
+ println("Concord: Community List skipped — storage was never attached")
+ return
+ }
+
+ val me = try {
+ nostr.signer.getPublicKeyAsync()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: Community List skipped — could not read the signer: ${e.message}")
+ return
+ }
+ if (me == null) {
+ println("Concord: Community List skipped — the signer has no public key")
+ return
+ }
+
+ val filter = Filter()
+ .kind(Kind(ConcordKind.COMMUNITY_LIST.toUShort()))
+ .author(me)
+
+ val events = try {
+ client.fetchEvents(ReqTarget.auto(listOf(filter)), timeout = 8.seconds).toVec()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: Community List fetch failed: ${e.message}")
+ return
+ }
+
+ println(
+ "Concord: Community List for ${me.toHex().take(8)}… came back with " +
+ "${events.size} event(s) from ${client.relays().size} relay(s)"
+ )
+
+ val fragments = events.mapNotNull { event ->
+ try {
+ val plaintext = nostr.signer.nip44DecryptAsync(me, event.content())
+ concordJson.decodeFromString(plaintext)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println(
+ "Concord: Community List fragment ${event.id().toHex().take(8)}… " +
+ "(d=${event.tagValue(ConcordTag.D)}) is unusable: ${e.message}"
+ )
+ null
+ }
+ }
+
+ val known = _memberships.value.mapTo(mutableSetOf()) { it.communityId }
+ val listed = fragments.listedMemberships()
+ val adopted = listed.filterNot { it.communityId in known }
+
+ println(
+ "Concord: Community List gave ${fragments.size} fragment(s), " +
+ "${listed.size} membership(s), ${adopted.size} new"
+ )
+
+ if (adopted.isEmpty()) return
+
+ val updated = _memberships.value + adopted
+ try {
+ store.saveMemberships(updated)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: could not store the adopted memberships: ${e.message}")
+ return
+ }
+
+ _memberships.value = updated
+ refreshPlanes()
+ }
+
+ /**
+ * Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining
+ * would mean. Nothing is joined, nothing is subscribed and no presence is announced here
+ * (CORD-05 §1: a bundle is passive until the user accepts).
+ */
+ suspend fun previewInvite(link: String): InvitePreview {
+ val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
+
+ val parsed = parseInviteLink(link)
+ ?: throw IllegalArgumentException("That is not a Concord invite link")
+
+ val bundleKey = inviteBundleKey(parsed.fragment.tokenHex)
+ ?: throw IllegalArgumentException("The invite link's token is malformed")
+
+
+ val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays)
+ .mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
+ .distinct()
+
+ if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
+
+ relays.forEach { relay ->
+ client.addRelay(relay)
+ client.connectRelay(relay)
+ }
+
+ val filter = Filter()
+ .kind(Kind(ConcordKind.INVITE_BUNDLE.toUShort()))
+ .author(PublicKey.fromBytes(parsed.signerHex.hexToBytes()))
+ .identifier(parsed.identifier)
+
+ val bundle = client
+ .fetchEvents(
+ ReqTarget.manual(relays.associateWith { listOf(filter) }),
+ timeout = 8.seconds
+ )
+ .toVec()
+ .firstOrNull()
+ ?: throw IllegalStateException("No invite bundle was found at that link")
+
+ if (isInviteTombstone(bundle.tagValue(ConcordTag.VSK))) {
+ throw IllegalStateException("This invite link has been revoked")
+ }
+
+ val invite = decryptInviteBundle(bundle.content(), bundleKey)
+ ?: throw IllegalStateException("The invite bundle could not be opened")
+
+ return preview(invite, link, parsed.fragment.relays)
+ }
+
+ /** The same preview for a Direct Invite, which arrived with no link to fetch. */
+ fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
+ preview(invite, link = null, fallbackRelays = emptyList())
+
+ /**
+ * Forgets a Direct Invite the user declined. It was never persisted, so this only clears the
+ * in-memory list — there is nothing on a relay to take back.
+ */
+ fun dismissDirectInvite(communityIdHex: String) {
+ _directInvites.update { invites ->
+ invites.filterNot { it.communityId.equals(communityIdHex, ignoreCase = true) }
+ }
+ }
+
+ /** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */
+ suspend fun join(preview: InvitePreview): CommunityState {
+ preview.problems.firstOrNull()?.let { throw IllegalArgumentException(it) }
+ if (preview.expired) throw IllegalArgumentException("That invite has expired")
+
+ val store = store ?: throw IllegalStateException("Concord storage is not ready")
+ val membership = preview.invite.toMembership(preview.relays)
+
+ val updated =
+ _memberships.value.filterNot { it.communityId == membership.communityId } + membership
+ store.saveMemberships(updated)
+ _memberships.value = updated
+
+ refreshPlanes()
+ subscribeCommunity(membership)
+
+ if (!preview.alreadyJoined) publishJoin(membership, preview.invite)
+
+ _directInvites.update { invites ->
+ invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
+ }
+
+ return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
+ ?: CommunityState(membership, null, emptyList())
+ }
+
+ /** A Channel's messages, oldest first, with reactions and edits folded on top. */
+ suspend fun channelMessages(channelIdHex: String): List =
+ store?.cachedRumors(channelIdHex).orEmpty().toChannelMessages()
+
+ /** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
+ fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
+
+ /** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
+ fun markChannelRead(channelIdHex: String) {
+ if ((unread[channelIdHex] ?: 0) == 0) return
+ unread = unread - channelIdHex
+ publishUnread(channelIdHex, 0)
+ }
+
+ /**
+ * Sends a message to a Channel (CORD-03 §3), and returns it as a reader will see it.
+ *
+ * Throws when the Channel is hidden behind a key we were never granted — a Private Channel is
+ * listable without being writable.
+ */
+ suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage =
+ publish(channelPlane(channelIdHex), ConcordKind.MESSAGE, content)
+ .toConcordMessage()
+ ?: throw IllegalStateException("Concord: could not read back the message just sent")
+
+ /**
+ * Reacts to a message (CORD-03 §2.3). The `e` tag names the target's *rumor* id, and `p`/`k`
+ * carry its author and kind, which is what lets a reader group it without a lookup.
+ */
+ suspend fun sendChannelReaction(
+ channelIdHex: String,
+ targetIdHex: String,
+ targetAuthorHex: String,
+ reaction: String,
+ ) {
+ publish(
+ plane = channelPlane(channelIdHex),
+ kind = ConcordKind.REACTION,
+ content = reaction,
+ extraTags = listOf(
+ Tag.event(EventId.parse(targetIdHex)),
+ Tag.publicKey(PublicKey.parse(targetAuthorHex)),
+ Tag.custom(ConcordTag.K, listOf(ConcordKind.MESSAGE.toString())),
+ ),
+ )
+ }
+
+ /**
+ * Replaces one of our own messages (CORD-03 §2.5). The `e` tag names the message being edited;
+ * the edit rides the same Channel plane, so it reaches exactly the readers the original did.
+ */
+ suspend fun sendChannelEdit(channelIdHex: String, targetIdHex: String, content: String) {
+ publish(
+ plane = channelPlane(channelIdHex),
+ kind = ConcordKind.EDIT,
+ content = content,
+ extraTags = listOf(Tag.event(EventId.parse(targetIdHex))),
+ )
+ }
+
+ private fun channelPlane(channelIdHex: String): Plane = planes.values.firstOrNull {
+ it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
+ } ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
+
+ /**
+ * The whole write path: build the rumor from the Channel's own key, wrap it under that key's
+ * stream half, cache it locally, then publish to the Community's own relays.
+ *
+ * The rumor is cached *before* it is published, so it is visible even if every relay is
+ * unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
+ * duplicating it. The relays are always the Community's, never the app's defaults.
+ */
+ private suspend fun publish(
+ plane: Plane,
+ kind: Int,
+ content: String,
+ extraTags: List = emptyList(),
+ ): UnsignedEvent {
+ val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
+ val author =
+ nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
+
+ val rumor = plane.key.rumor(
+ author = author,
+ kind = kind.toUShort(),
+ content = content,
+ createdAt = Clock.System.now(),
+ extraTags = extraTags,
+ )
+ val wrap = plane.key.wrap(rumor, nostr.signer)
+
+ store?.cacheRumor(plane.scopeIdHex, wrap.id().toHex(), rumor)
+ _revision.update { it + 1 }
+
+ // Always the Community's own relays, never the app's defaults (see [subscribeCommunity]).
+ val relays = _memberships.value
+ .firstOrNull { it.communityId == plane.communityIdHex }
+ ?.relays
+ .orEmpty()
+ .mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
+ if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
+
+ client.sendEvent(
+ event = wrap,
+ target = SendEventTarget.to(relays),
+ ackPolicy = AckPolicy.none()
+ ).failed.forEach { (relay, reason) -> println("Concord: $relay refused a write: $reason") }
+
+ return rumor
+ }
+
+ /** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
+ private fun publishUnread(channelIdHex: String, count: Int) {
+ _communities.update { states ->
+ if (states.none { state -> state.channels.any { it.idHex == channelIdHex } }) return@update states
+ states.map { state ->
+ state.copy(
+ channels = state.channels.map {
+ if (it.idHex == channelIdHex) it.copy(unreadCount = count) else it
+ }
+ )
+ }
+ }
+ }
+
+ /** Counts one incoming message against its Channel, ignoring our own and anything but a message. */
+ private fun countUnread(channelIdHex: String, rumor: UnsignedEvent) {
+ if (rumor.kind().asU16() != ConcordKind.MESSAGE.toUShort()) return
+ if (rumor.author() == nostr.signer.publicKeyFlow.value) return
+
+ val count = (unread[channelIdHex] ?: 0) + 1
+ unread = unread + (channelIdHex to count)
+ publishUnread(channelIdHex, count)
+ }
+
+ /**
+ * Rebuilds the plane index and the Community read model from the memberships and folds we hold.
+ *
+ * Returns the communities whose set of plane addresses changed, because those are exactly the
+ * ones whose subscription is now stale — a Control edition that adds a Channel adds an address.
+ */
+ private fun refreshPlanes(): Set {
+ val previous = planes
+ val next = mutableMapOf()
+ val states = mutableListOf()
+
+ for (membership in _memberships.value) {
+ val communityId = membership.communityId.hex32()
+ val root = membership.communityRoot.hex32()
+ val fold = folds[membership.communityId]
+ val granted = membership.channels.associateBy { it.id }
+
+ if (communityId != null && root != null) {
+ membership.controlPk?.let { controlPkHex ->
+ if (controlPkHex.hex32() != null) {
+ val key =
+ controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
+ next[key.streamPublicKeyHex] = Plane(
+ communityIdHex = membership.communityId,
+ role = PlaneRole.Control,
+ key = key,
+ scopeIdHex = membership.communityId,
+ )
+ }
+ }
+
+ val guestbook = guestbookPlaneKey(root, communityId, membership.rootEpoch)
+ next[guestbook.streamPublicKeyHex] = Plane(
+ communityIdHex = membership.communityId,
+ role = PlaneRole.Guestbook,
+ key = guestbook,
+ scopeIdHex = membership.communityId,
+ )
+ }
+
+ val channelIds = (granted.keys + fold?.channels?.keys.orEmpty()).distinct()
+ val channels = channelIds.mapNotNull { channelIdHex ->
+ val meta = fold?.channels?.get(channelIdHex)
+ if (meta?.deleted == true) return@mapNotNull null
+
+ val stored = granted[channelIdHex]
+ val channelId = channelIdHex.hex32()
+ val secret = stored?.key?.hex32()
+ ?: if (meta?.isPrivate != true) root else null
+ val epoch = stored?.epoch ?: membership.rootEpoch
+
+ var plane: PlaneKey? = null
+ if (channelId != null && secret != null) {
+ plane = channelPlaneKey(secret, channelId, epoch)
+ next[plane.streamPublicKeyHex] = Plane(
+ communityIdHex = membership.communityId,
+ role = PlaneRole.Channel,
+ key = plane,
+ scopeIdHex = channelIdHex,
+ )
+ }
+
+ ConcordChannel(
+ idHex = channelIdHex,
+ name = meta?.name ?: stored?.name ?: channelIdHex.take(8),
+ private = meta?.isPrivate ?: stored?.private ?: false,
+ epoch = epoch,
+ hasKey = plane != null,
+ // Carried across the re-index rather than recomputed: a Control edition must not
+ // silently clear every badge, the same way ChatRepository preserves its counters.
+ unreadCount = unread[channelIdHex] ?: 0,
+ )
+ }
+
+ states += CommunityState(
+ membership = membership,
+ meta = fold?.meta,
+ channels = channels.sortedBy { it.name.lowercase() },
+ )
+ }
+
+ planes = next
+ _communities.value = states
+
+ return _memberships.value
+ .filter { membership ->
+ previous.planeKeys(membership.communityId) != next.planeKeys(membership.communityId)
+ }
+ .map { it.communityId }
+ .toSet()
+ }
+
+ /** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */
+ private suspend fun refreshControl(communityIdHex: String) {
+ val store = store ?: return
+ val editions =
+ store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) }
+ folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex))
+
+ if (communityIdHex in refreshPlanes()) {
+ _memberships.value.firstOrNull { it.communityId == communityIdHex }
+ ?.let { subscribeCommunity(it) }
+ }
+ }
+
+ /**
+ * Subscribes to one Community: connect its relays, then ask for every plane's address.
+ *
+ * `channel` and `epoch` are multi-letter tags, and Nostr filters only accept single-letter ones,
+ * so plane addresses are the finest granularity available. That is the right level anyway — one
+ * key per plane — and the `channel`/`epoch` binding is a strict check after decryption.
+ */
+ private suspend fun subscribeCommunity(membership: Membership) {
+ val client = nostr.client ?: return
+ val id = subscriptionId(membership.communityId)
+ client.unsubscribe(id)
+
+ val relays = membership.relays
+ .mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
+ .distinct()
+
+ if (relays.isEmpty()) return
+
+ relays.forEach { relay ->
+ client.addRelay(relay)
+ client.connectRelay(relay)
+ }
+
+ val filters = planes.values
+ .filter { it.communityIdHex == membership.communityId }
+ .mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
+ .distinct()
+ .map {
+ Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it))
+ }
+
+ if (filters.isEmpty()) return
+
+ client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id)
+ }
+
+ /** Publishes a still-fetchable Guestbook Join (CORD-02 §5) — each member's own word. */
+ private suspend fun publishJoin(membership: Membership, invite: CommunityInvite?) {
+ val client = nostr.client ?: return
+ val communityId = membership.communityId.hex32() ?: return
+ val root = membership.communityRoot.hex32() ?: return
+ val author =
+ nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
+
+ val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch)
+
+ val extraTags = invite?.creatorNpub
+ ?.let { creator ->
+ listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
+ }.orEmpty()
+
+ val rumor = plane.rumor(
+ author = author,
+ kind = ConcordKind.JOIN_LEAVE.toUShort(),
+ content = "join",
+ createdAt = Clock.System.now(),
+ extraTags = extraTags,
+ )
+ val wrap = plane.wrap(rumor, nostr.signer)
+
+ val targets =
+ membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
+
+ client.sendEvent(
+ event = wrap,
+ target = SendEventTarget.to(targets),
+ ackPolicy = AckPolicy.none()
+ )
+ }
+
+ private fun subscriptionId(communityIdHex: String): String =
+ "$SUBSCRIPTION_PREFIX$communityIdHex"
+
+ private fun preview(
+ invite: CommunityInvite,
+ link: String?,
+ fallbackRelays: List,
+ ): InvitePreview {
+ val relays = invite.relaySet(fallbackRelays)
+ val problems =
+ invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
+
+ return InvitePreview(
+ link = link,
+ invite = invite,
+ relays = relays,
+ problems = problems,
+ expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
+ alreadyJoined = _memberships.value.any {
+ it.communityId.equals(
+ invite.communityId,
+ ignoreCase = true
+ )
+ },
+ )
+ }
+
+ private companion object {
+ const val SUBSCRIPTION_PREFIX = "concord:"
+ }
+}
+
+/** What a plane is for — the Community-wide Control and Guestbook planes, or one Channel. */
+private enum class PlaneRole { Control, Guestbook, Channel }
+
+/**
+ * One plane address we hold: the key to decrypt it, the role that says how to fold it, and the
+ * [scopeIdHex] it caches under.
+ */
+private class Plane(
+ val communityIdHex: String,
+ val role: PlaneRole,
+ val key: PlaneKey,
+ val scopeIdHex: String,
+)
+
+private fun Map.planeKeys(communityIdHex: String): Set =
+ filterValues { it.communityIdHex == communityIdHex }.keys
+
+/** The first content value of a tag, or null. Used for tags read without their companion fields. */
+private fun Event.tagValue(kind: String): String? =
+ tags().toVec().firstOrNull { it.kind() == kind }?.content()
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt
new file mode 100644
index 0000000..47afb30
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt
@@ -0,0 +1,246 @@
+package su.reya.coop.concord
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import rust.nostr.sdk.UnsignedEvent
+import kotlin.time.Instant
+
+internal val concordJson = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+}
+
+@Serializable
+data class Membership(
+ /** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
+ val communityId: String,
+ /** Owner x-only pubkey hex, the other half of the `community_id` commitment. */
+ val owner: String,
+ /** 32-byte hex salt; not secret, travels in invites so anyone can recompute the commitment. */
+ val ownerSalt: String,
+ /** 32-byte hex `community_root`. The base access key — never leaves the encrypted store. */
+ val communityRoot: String,
+ /** Epoch the root was issued at (CORD-02 §3). */
+ val rootEpoch: ULong,
+ /** Control Plane signer pubkey (CORD-02 §5), used to *address* the plane. Trusted on trust. */
+ val controlPk: String? = null,
+ /** Staff-only write key. v1 never writes to the Control Plane, so this stays null. */
+ val controlRoot: String? = null,
+ /** The Community's relays — always from the invite, never the app's defaults (see PLAN.md 13.2). */
+ val relays: List = emptyList(),
+ /** Last known name, cached so a community renders before its Control fold lands. */
+ val name: String? = null,
+ /** Channels this invite actually granted a key for. */
+ val channels: List = emptyList(),
+)
+
+@Serializable
+data class StoredChannelKey(
+ val id: String,
+ val key: String,
+ val epoch: ULong,
+ val name: String? = null,
+ val private: Boolean = false,
+)
+
+@Serializable
+data 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: ULong = 0u,
+ @SerialName("control_pk") val controlPk: String? = null,
+ val channels: List = emptyList(),
+ val relays: List = emptyList(),
+ val name: String? = null,
+ val icon: ConcordIcon? = null,
+ /** Unix **milliseconds** — see [expiryToEpochSeconds]. Optional. */
+ @SerialName("expires_at") val expiresAt: Long? = null,
+ @SerialName("creator_npub") val creatorNpub: String? = null,
+ val label: String? = null,
+)
+
+/** One granted Channel inside a bundle. `key` is `community_root` for a Public one (CORD-03 §1). */
+@Serializable
+data class InviteChannel(
+ val id: String = "",
+ val key: String = "",
+ val epoch: ULong = 0u,
+ val name: String? = null,
+)
+
+@Serializable
+data class ConcordIcon(
+ val url: String? = null,
+ val key: String? = null,
+ val nonce: String? = null,
+ val hash: String? = null,
+)
+
+/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
+@Serializable
+data class CommunityMeta(
+ val name: String? = null,
+ val description: String? = null,
+ /** The Community's own relay set. This is the authority; the invite's copy is a snapshot. */
+ val relays: List = emptyList(),
+ val icon: ConcordIcon? = null,
+ val banner: ConcordIcon? = null,
+ /** Disappearing-messages timer in seconds (CORD-08). Read only; v1 does not enforce it. */
+ @SerialName("message_expiration") val messageExpiration: Long? = null,
+)
+
+/** Channel metadata — the `vsk 2` entity's content (CORD-03 §2). */
+@Serializable
+data class ChannelMeta(
+ val name: String? = null,
+ @SerialName("private") val isPrivate: Boolean = false,
+ /** Terminal: the id is never reused and clients drop the Channel (CORD-03 §2). */
+ val deleted: Boolean = false,
+)
+
+data class ControlEdition(
+ val vsk: Int,
+ val eidHex: String,
+ val version: ULong,
+ val prevHashHex: String?,
+ val content: String,
+ val actorHex: String,
+ val rumorIdHex: String,
+ val createdAt: Instant,
+)
+
+/** The projected Control state v1 uses: the Community's metadata and its Channel list. */
+data class ControlFold(
+ val meta: CommunityMeta?,
+ val channels: Map,
+)
+
+/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
+data class ConcordChannel(
+ val idHex: String,
+ val name: String,
+ val private: Boolean,
+ val epoch: ULong,
+ /** False for a Private Channel we were never granted — listable, but not readable. */
+ val hasKey: Boolean,
+ /**
+ * Messages from others since this Channel was last opened. In memory only, exactly like
+ * `Room.unreadCount`: it survives a Control re-fold, but not a restart.
+ */
+ val unreadCount: Int = 0,
+)
+
+/** A Community with its Control fold applied. */
+data class CommunityState(
+ val membership: Membership,
+ /** Null until a `vsk 0` edition has been folded; the invite's name fills in before that. */
+ val meta: CommunityMeta?,
+ val channels: List,
+)
+
+/** One emoji and everyone who used it on a message (CORD-03 §2.3), grouped for display. */
+data class ConcordReaction(
+ val emoji: String,
+ val authors: List,
+)
+
+/** A Chat-plane message, already unwrapped and signature-checked. */
+data class ConcordMessage(
+ val idHex: String,
+ val author: String,
+ val content: String,
+ val createdAt: Instant,
+ /** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
+ val timestampMs: Long,
+ val reactions: List = emptyList(),
+ /** True once the author's own Edit (kind `3302`) has replaced [content]. */
+ val edited: Boolean = false,
+)
+
+/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */
+data class InvitePreview(
+ /** Null for a Direct Invite, which arrives with no link. */
+ val link: String?,
+ val invite: CommunityInvite,
+ /** Resolved relay set: the bundle's, falling back to the link's bootstrap relays. */
+ val relays: List,
+ /** Non-empty means the bundle is refused — these are the reasons, in plain English. */
+ val problems: List,
+ val expired: Boolean,
+ val alreadyJoined: Boolean,
+) {
+ val joinable: Boolean get() = problems.isEmpty() && !expired
+}
+
+fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
+
+internal fun UnsignedEvent.concordTimestampMs(): Long {
+ val ms = tags().toVec()
+ .firstOrNull { it.kind() == ConcordTag.MS }
+ ?.content()
+ ?.toIntOrNull()
+ ?.takeIf { it in 0..999 }
+ return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
+}
+
+internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
+ if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
+ return ConcordMessage(
+ idHex = id()?.toHex().orEmpty(),
+ author = author().toHex(),
+ content = content(),
+ createdAt = Instant.fromEpochSeconds(createdAt().asSecs().toLong()),
+ timestampMs = concordTimestampMs(),
+ )
+}
+
+/**
+ * Projects one Channel's cached rumors into the timeline: its messages, with reactions and edits
+ * folded on top.
+ *
+ * Every one of these kinds names its target by the target's *rumor* id, never the outer wrap's —
+ * that id differs on every re-wrap (CORD-03 §2). A reaction for a message we no longer hold is
+ * dropped along with it. An Edit counts only from the message's own author: the `e` tag is a claim,
+ * and authorship is what the seal actually proves.
+ */
+internal fun List.toChannelMessages(): List {
+ val messages = filter { it.isKind(ConcordKind.MESSAGE) }
+ .mapNotNull { it.toConcordMessage() }
+ .sortedBy { it.timestampMs }
+
+ val reactions = filter { it.isKind(ConcordKind.REACTION) }
+ .mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
+ .groupBy({ it.first }, { it.second })
+ .mapValues { (_, rumors) ->
+ rumors.groupBy { it.content() }
+ .map { (emoji, reactors) ->
+ ConcordReaction(
+ emoji = emoji.ifEmpty { "+" },
+ authors = reactors.map { it.author().toHex() }.distinct(),
+ )
+ }
+ }
+
+ val edits = filter { it.isKind(ConcordKind.EDIT) }
+ .mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
+ .groupBy({ it.first }, { it.second })
+ .mapValues { (_, candidates) -> candidates.maxBy { it.concordTimestampMs() } }
+
+ return messages.map { message ->
+ val edit = edits[message.idHex]?.takeIf { it.author().toHex() == message.author }
+ message.copy(
+ content = edit?.content() ?: message.content,
+ edited = edit != null,
+ reactions = reactions[message.idHex].orEmpty(),
+ )
+ }
+}
+
+private fun UnsignedEvent.isKind(expected: Int): Boolean = kind().asU16() == expected.toUShort()
+
+/** The target of a reaction, edit or delete: the first `e` tag (CORD-03 §2.3–2.5). */
+private fun UnsignedEvent.targetHex(): String? =
+ tags().toVec().firstOrNull { it.kind() == ConcordTag.E }?.content()
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt
new file mode 100644
index 0000000..8c5070c
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt
@@ -0,0 +1,179 @@
+package su.reya.coop.concord
+
+import kotlinx.coroutines.CancellationException
+import rust.nostr.sdk.AsyncNostrSigner
+import rust.nostr.sdk.Event
+import rust.nostr.sdk.EventBuilder
+import rust.nostr.sdk.Keys
+import rust.nostr.sdk.Kind
+import rust.nostr.sdk.Nip44Version
+import rust.nostr.sdk.PublicKey
+import rust.nostr.sdk.Tag
+import rust.nostr.sdk.Timestamp
+import rust.nostr.sdk.UnsignedEvent
+import rust.nostr.sdk.nip44Decrypt
+import rust.nostr.sdk.nip44Encrypt
+import kotlin.time.Instant
+
+enum class SealForm(private val kindValue: Int) {
+ Encrypted(ConcordKind.SEAL),
+ Plaintext(ConcordKind.PLAINTEXT_SEAL);
+
+ val kind: UShort get() = kindValue.toUShort()
+}
+
+data class ChatBinding(val channelIdHex: String, val epoch: ULong)
+
+class PlaneKey(
+ val read: GroupKey,
+ val stream: GroupKey?,
+ val streamPublicKeyHex: String,
+ val form: SealForm,
+ val chat: ChatBinding?,
+)
+
+fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
+ val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
+ return PlaneKey(
+ read = key,
+ stream = key,
+ streamPublicKeyHex = key.publicKey.toHex(),
+ form = SealForm.Encrypted,
+ chat = ChatBinding(channelId.toHex(), epoch),
+ )
+}
+
+fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
+ val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
+ return PlaneKey(
+ read = key,
+ stream = key,
+ streamPublicKeyHex = key.publicKey.toHex(),
+ form = SealForm.Encrypted,
+ chat = null,
+ )
+}
+
+fun controlPlaneKey(
+ communityRoot: ByteArray,
+ communityId: ByteArray,
+ epoch: ULong,
+ streamPublicKeyHex: String,
+): PlaneKey {
+ val key = groupKey(ConcordLabel.CONTROL, communityRoot, communityId, epoch)
+ return PlaneKey(
+ read = key,
+ stream = null,
+ streamPublicKeyHex = streamPublicKeyHex,
+ form = SealForm.Plaintext,
+ chat = null,
+ )
+}
+
+fun PlaneKey.rumor(
+ author: PublicKey,
+ kind: UShort,
+ content: String,
+ createdAt: Instant,
+ extraTags: List = emptyList(),
+): UnsignedEvent {
+ val tags = buildList {
+ chat?.let {
+ add(Tag.custom(ConcordTag.CHANNEL, listOf(it.channelIdHex)))
+ add(Tag.custom(ConcordTag.EPOCH, listOf(it.epoch.toString())))
+ }
+ add(Tag.custom(ConcordTag.MS, listOf(msOf(createdAt).toString())))
+ addAll(extraTags)
+ }
+ return EventBuilder(Kind(kind), content)
+ .tags(tags)
+ .customCreatedAt(Timestamp.fromSecs(createdAt.epochSeconds.toULong()))
+ .finalizeUnsigned(author)
+ .ensureId()
+}
+
+suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event {
+ val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
+ val createdAt = rumor.createdAt()
+
+ val rumorJson = rumor.asJson()
+ val content = if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson
+
+ val seal = try {
+ EventBuilder(Kind(form.kind), content).customCreatedAt(createdAt).finalizeAsync(signer)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
+ }
+
+ check(seal.author() == rumor.author()) {
+ "Concord: seal author ${seal.author().toHex()} does not match rumor author ${
+ rumor.author().toHex()
+ }"
+ }
+
+ return try {
+ val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
+ Keys(stream.secretKey).use { keys ->
+ EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
+ .tags(listOf(ephemeral))
+ .customCreatedAt(createdAt)
+ .finalize(keys)
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ throw IllegalStateException("Concord: failed to wrap seal: ${e.message}", e)
+ }
+}
+
+fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
+ if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
+ if (event.author().toHex() != streamPublicKeyHex) return null
+ if (!event.verify()) return null
+
+ val seal = runCatching {
+ Event.fromJson(nip44Decrypt(read.secretKey, read.publicKey, event.content()))
+ }.getOrNull() ?: return null
+ if (!seal.verify()) return null
+
+ val sealKind = seal.kind().asU16()
+ val rumorJson = when {
+ sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
+ nip44Decrypt(read.secretKey, read.publicKey, seal.content())
+ }.getOrNull() ?: return null
+ // Plaintext seal: the rumor's bytes are already the content, never re-parsed as an event.
+ sealKind == SealForm.Plaintext.kind && form == SealForm.Plaintext -> seal.content()
+ else -> return null
+ }
+
+ val rumor =
+ runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
+ // NIP-59's impersonation check: the seal proves who wrote the rumor inside it.
+ if (rumor.author() != seal.author()) return null
+ if (!bindsToThisPlane(rumor)) return null
+ return rumor
+}
+
+private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
+ val expected = chat ?: return true
+ val tags = rumor.tags().toVec()
+ val channel = tags.firstOrNull { it.kind() == ConcordTag.CHANNEL }?.content()
+ val epoch = tags.firstOrNull { it.kind() == ConcordTag.EPOCH }?.content()
+ return channel == expected.channelIdHex && epoch == expected.epoch.toString()
+}
+
+private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
+
+private fun concordKind(kind: Int): UShort = kind.toUShort()
+
+private fun nip44Seal(key: GroupKey, plaintext: String): String {
+ val size = plaintext.encodeToByteArray().size
+ require(size <= MAX_PLAINTEXT_BYTES) {
+ "Concord: NIP-44 plaintext is $size bytes, over the $MAX_PLAINTEXT_BYTES cap"
+ }
+ return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
+}
+
+private const val MAX_PLAINTEXT_BYTES = 65_535
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt
new file mode 100644
index 0000000..09a042b
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt
@@ -0,0 +1,102 @@
+package su.reya.coop.concord
+
+import kotlinx.coroutines.CancellationException
+import rust.nostr.sdk.EventBuilder
+import rust.nostr.sdk.Filter
+import rust.nostr.sdk.Keys
+import rust.nostr.sdk.Kind
+import rust.nostr.sdk.KindStandard
+import rust.nostr.sdk.Tag
+import rust.nostr.sdk.UnsignedEvent
+import su.reya.coop.AppStorage
+import su.reya.coop.nostr.Nostr
+
+class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
+ suspend fun loadMemberships(): List {
+ val raw = try {
+ storage.getSecret(MEMBERSHIPS_KEY)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: could not read memberships: ${e.message}")
+ return emptyList()
+ } ?: return emptyList()
+
+ return try {
+ concordJson.decodeFromString>(raw)
+ } catch (e: Exception) {
+ println("Concord: stored memberships are unreadable: ${e.message}")
+ emptyList()
+ }
+ }
+
+ suspend fun saveMemberships(memberships: List) {
+ try {
+ storage.setSecret(MEMBERSHIPS_KEY, concordJson.encodeToString(memberships))
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ throw IllegalStateException("Concord: could not store memberships: ${e.message}", e)
+ }
+ }
+
+ suspend fun clearMemberships() {
+ try {
+ storage.clear(MEMBERSHIPS_KEY)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: could not clear memberships: ${e.message}")
+ }
+ }
+
+ suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
+ try {
+ val event =
+ EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
+ .tags(
+ listOf(
+ Tag.identifier(wrapIdHex),
+ Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
+ Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
+ )
+ )
+ .finalizeAsync(Keys.generate())
+
+ nostr.client?.database()?.saveEvent(event)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: failed to cache rumor: ${e.message}")
+ }
+ }
+
+ /** Every cached rumor for one scope, oldest first. */
+ suspend fun cachedRumors(scopeIdHex: String): List {
+ val events = try {
+ val filter = Filter()
+ .kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
+ .reference(scopeIdHex)
+ nostr.client?.database()?.query(filter)?.toVec() ?: return emptyList()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ println("Concord: failed to read cached rumors: ${e.message}")
+ return emptyList()
+ }
+
+ return events
+ .mapNotNull {
+ runCatching {
+ UnsignedEvent.fromJson(it.content()).ensureId()
+ }.getOrNull()
+ }
+ .sortedBy { it.createdAt().asSecs() }
+ }
+
+ private companion object {
+ const val MEMBERSHIPS_KEY = "concord_memberships"
+ const val INDEX_SCOPE_TAG = "r"
+ const val INDEX_KIND_TAG = "k"
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
index ce06e65..ea82606 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
@@ -32,6 +32,8 @@ import rust.nostr.sdk.SleepWhenIdle
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.initLogger
+import su.reya.coop.AppStorage
+import su.reya.coop.concord.ConcordManager
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -50,6 +52,7 @@ class Nostr(
val messages = MessageManager(this)
val profiles = ProfileManager(this)
val relays = RelayManager(this)
+ val concord = ConcordManager(this)
private val isInitialized = MutableStateFlow(false)
@@ -64,10 +67,18 @@ class Nostr(
_newEvents.tryEmit(event)
}
- suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
+ suspend fun init(
+ dbPath: String,
+ storage: AppStorage,
+ logLevel: LogLevel = LogLevel.WARN,
+ ) {
try {
if (isInitialized.value) return
+ // Concord's keys live in the encrypted store, which only the Android layer can build.
+ // Handing it over here keeps it out of the Context-free singleton's constructor.
+ concord.attach(storage)
+
// Initialize the logger for nostr client
initLogger(logLevel)
@@ -144,16 +155,35 @@ class Nostr(
val notifications = client?.notifications() ?: return@supervisorScope
val giftWrapQueue = Channel(1024)
+ val concordQueue = Channel(1024)
+
var processedCount = 0
var eoseReceived = false
+ try {
+ concord.restore()
+ } catch (e: Exception) {
+ println("Failed to restore Concord state: ${e.message}")
+ }
+
+ launch(defaultDispatcher) { concord.sync() }
+
+ launch(defaultDispatcher) {
+ for (event in concordQueue) {
+ try {
+ concord.handlePlaneEvent(event)
+ } catch (e: Exception) {
+ println("Failed to handle Concord plane event: ${e.message}")
+ }
+ }
+ }
+
launch(defaultDispatcher) {
for (event in giftWrapQueue) {
val rumor = messages.extractRumor(event)
processedCount++
- // Trigger new message notification
- if (rumor != null) {
+ if (rumor != null && !concord.onInboxRumor(rumor)) {
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
@@ -214,7 +244,11 @@ class Nostr(
}
KindStandard.GIFT_WRAP -> {
- giftWrapQueue.send(event)
+ if (concord.isPlaneAddress(event.author().toHex())) {
+ concordQueue.send(event)
+ } else {
+ giftWrapQueue.send(event)
+ }
}
else -> {}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ConcordRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ConcordRepository.kt
new file mode 100644
index 0000000..a39663c
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ConcordRepository.kt
@@ -0,0 +1,88 @@
+package su.reya.coop.repository
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import su.reya.coop.concord.CommunityInvite
+import su.reya.coop.concord.CommunityState
+import su.reya.coop.concord.ConcordMessage
+import su.reya.coop.concord.InvitePreview
+import su.reya.coop.nostr.Nostr
+import su.reya.coop.viewmodel.ErrorHost
+import su.reya.coop.viewmodel.createErrorHost
+
+class ConcordRepository(
+ private val nostr: Nostr,
+ private val scope: CoroutineScope,
+ private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
+) : ErrorHost by createErrorHost() {
+ private val concord = nostr.concord
+
+ val communities: StateFlow> = concord.communities
+
+ /** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
+ val directInvites: StateFlow> = concord.directInvites
+
+ /** True once memberships have been loaded, so an empty list means "joined nothing". */
+ val isReady: StateFlow = concord.restored
+
+ /** Bumped whenever a plane rumor lands, so an open Channel knows to re-read its history. */
+ val revision: StateFlow = concord.revision
+
+ /** Fetches and decrypts the bundle behind a public invite link. */
+ suspend fun previewInvite(link: String): InvitePreview? = attempt { concord.previewInvite(link) }
+
+ /** The same preview for a Direct Invite, which already arrived with its bundle in hand. */
+ fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
+ concord.previewDirectInvite(invite)
+
+ /** Accepts an invite: persist the keys, connect, subscribe, announce the join. */
+ suspend fun join(preview: InvitePreview): CommunityState? = attempt { concord.join(preview) }
+
+ /** A Channel's history, oldest first, from the local cache the live subscription keeps current. */
+ suspend fun channelMessages(channelIdHex: String): List =
+ attempt { concord.channelMessages(channelIdHex) }.orEmpty()
+
+ suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? =
+ attempt { concord.sendChannelMessage(channelIdHex, content) }
+
+ /** Reacts to one message; `targetAuthorHex` is the message's author, which rides the `p` tag. */
+ suspend fun sendReaction(
+ channelIdHex: String,
+ targetIdHex: String,
+ targetAuthorHex: String,
+ reaction: String,
+ ) {
+ attempt { concord.sendChannelReaction(channelIdHex, targetIdHex, targetAuthorHex, reaction) }
+ }
+
+ /** Replaces one of our own messages with [content]. */
+ suspend fun editMessage(channelIdHex: String, targetIdHex: String, content: String) {
+ attempt { concord.sendChannelEdit(channelIdHex, targetIdHex, content) }
+ }
+
+ /** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
+ fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
+
+ fun dismissDirectInvite(communityIdHex: String) = concord.dismissDirectInvite(communityIdHex)
+
+ fun resetInternalState() {
+ scope.launch(defaultDispatcher) { attempt { concord.reset() } }
+ }
+
+ /** Runs one Concord call off the main thread, reporting any failure instead of throwing. */
+ private suspend fun attempt(block: suspend () -> T): T? = withContext(defaultDispatcher) {
+ try {
+ block()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ showError("Error: ${e.message}")
+ null
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChannelScreenViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChannelScreenViewModel.kt
new file mode 100644
index 0000000..aa10616
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChannelScreenViewModel.kt
@@ -0,0 +1,83 @@
+package su.reya.coop.viewmodel
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import su.reya.coop.Profile
+import su.reya.coop.concord.ConcordChannel
+import su.reya.coop.concord.ConcordMessage
+import su.reya.coop.repository.AccountRepository
+import su.reya.coop.repository.ConcordRepository
+
+class ChannelScreenViewModel(
+ val communityId: String,
+ val channelId: String,
+ accountRepository: AccountRepository,
+ private val concordRepository: ConcordRepository,
+) : ViewModel(), ErrorHost by concordRepository {
+ val currentUser: StateFlow = accountRepository.currentUserProfile
+
+ val channel: StateFlow = concordRepository.communities
+ .map { communities ->
+ communities
+ .firstOrNull { it.membership.communityId == communityId }
+ ?.channels
+ ?.firstOrNull { it.idHex == channelId }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
+
+ var loading by mutableStateOf(true)
+ val messages = mutableStateListOf()
+
+ private var reloadJob: Job? = null
+
+ init {
+ reload()
+
+ viewModelScope.launch {
+ concordRepository.revision.drop(1).collect { reload() }
+ }
+ }
+
+ private fun reload() {
+ reloadJob?.cancel()
+ reloadJob = viewModelScope.launch {
+ val loaded = concordRepository.channelMessages(channelId)
+ // The whole list, not just its ids: a reaction or an edit changes a message without
+ // changing which messages there are.
+ if (messages != loaded) {
+ messages.clear()
+ messages.addAll(loaded)
+ }
+ loading = false
+ concordRepository.markChannelRead(channelId)
+ }
+ }
+
+ fun sendMessage(text: String) {
+ if (text.isBlank()) return
+ viewModelScope.launch { concordRepository.sendMessage(channelId, text) }
+ }
+
+ fun sendReaction(message: ConcordMessage, reaction: String) {
+ viewModelScope.launch {
+ concordRepository.sendReaction(channelId, message.idHex, message.author, reaction)
+ }
+ }
+
+ /** Replaces one of our own messages; [targetIdHex] is the message being edited. */
+ fun editMessage(targetIdHex: String, text: String) {
+ if (text.isBlank()) return
+ viewModelScope.launch { concordRepository.editMessage(channelId, targetIdHex, text) }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ConcordViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ConcordViewModel.kt
new file mode 100644
index 0000000..a1d1725
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ConcordViewModel.kt
@@ -0,0 +1,27 @@
+package su.reya.coop.viewmodel
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.StateFlow
+import su.reya.coop.concord.CommunityInvite
+import su.reya.coop.concord.CommunityState
+import su.reya.coop.concord.InvitePreview
+import su.reya.coop.repository.ConcordRepository
+
+class ConcordViewModel(
+ private val repository: ConcordRepository,
+) : ViewModel(), ErrorHost by repository {
+ val communities: StateFlow> = repository.communities
+ val directInvites: StateFlow> = repository.directInvites
+ val isReady: StateFlow = repository.isReady
+
+ fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
+ repository.previewDirectInvite(invite)
+
+ suspend fun previewInvite(link: String): InvitePreview? = repository.previewInvite(link)
+
+ suspend fun join(preview: InvitePreview): CommunityState? = repository.join(preview)
+
+ fun dismissDirectInvite(communityIdHex: String) = repository.dismissDirectInvite(communityIdHex)
+
+ fun resetInternalState() = repository.resetInternalState()
+}