Author SHA1 Message Date
reya 1e4eb697a4 . 2026-09-15 16:30:05 +07:00
reya 5b72b07ca8 add concord reactions and edits 2026-09-15 15:17:14 +07:00
reya 63ef074e8f add simple ui 2026-09-15 11:07:41 +07:00
reya 9230628441 update concord backend 2026-09-15 08:26:07 +07:00
28 changed files with 2511 additions and 665 deletions
+290 -51
View File
@@ -12,6 +12,27 @@ Implementation plan for adding **Concord** communities and channels to Coop.
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
@@ -91,20 +112,23 @@ Concord is 8 CORDs. Building all at once contradicts requirement 1.
|---|---|
| **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` |
| **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 / Edits / Threads / WebXDC | Not needed for a first cut. |
| Community List (`33302`) | Cross-device sync; single-device + `AppStorage` covers v1. |
| 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.
@@ -181,45 +205,84 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue
| `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/<naddr>#<fragment>` 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/`
| File | Contents |
|---|---|
| `Community.kt` | UI-facing models + derived flows, mirroring `Room.kt` / `RoomUiState` |
| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, `MutableStateFlow`, `stateIn(scope, WhileSubscribed(5000), …)` |
| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel` |
| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel` (`mutableStateListOf<UnsignedEvent>`) |
### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/`
**Delivered in M5.**
| File | Contents |
|---|---|
| `CommunitiesScreen.kt` | Joined communities list + FAB → Join |
| `CommunityScreen.kt` | Channel list for one community |
| `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join |
| `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states |
| `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 and M2 were verified during development and the checks were then discarded, so the tree carries no `commonTest` or `iosTest` sources for Concord.
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 (7 files, all small)
### 5.5 Modified
Wired in M3 (before the UI existed):
| File | Change |
|---|---|
| `shared/.../nostr/Nostr.kt` | add `val concord`, `init(dbPath, storage)`, 2 routing branches |
| `shared/.../nostr/Nostr.kt` | `val concord`, `init(dbPath, storage)`, 2 routing branches |
| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` |
| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, add to `App(...)` params |
| `composeApp/.../App.kt` | factory branch, `viewModel(...)`, 3 × `entry<…>`, snackbar collector |
| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` |
| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList` |
| `composeApp/src/androidMain/composeResources/drawable/ic_communities.xml` | icon |
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<ReactionGroup>`, 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()` |
---
@@ -347,7 +410,7 @@ This is a plain SHA-256 commitment, **not** the HKDF construction.
| `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 needed only if community creation is added ([M4.5](#m45--community-creation-optional)).
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)
@@ -544,6 +607,28 @@ then `UnsignedEvent.fromJson(it.content())`. The Control plane uses the same mec
**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<channelIdHex, Int>` 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<Long>` 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
@@ -633,15 +718,18 @@ If a deep link is wanted later, add `coop://invite?url=<urlencoded>` and let Coo
## 11. UI
Mirrors the DM screens structurally so it feels native to Coop.
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 avatar (`Avatar`), name, channel count. Empty state per `ContactListScreen` convention. |
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock icon on private. `#general` comes from genesis. |
| `ChannelScreen` | `ChatScreen` | Reuse `DateSeparator`, `ChatInput`, `Avatar`. Discord-style (author shown per message) rather than Coop's DM style. |
| `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
@@ -666,8 +754,8 @@ And one entry in `BottomMenuList` (`HomeScreen.kt:785-791`) plus `ic_communities
### 11.2 UI honesty
- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community settings/info row.
- Warn on community creation (if implemented) that losing the owner key kills the community — there is no succession by design.
- 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.
---
@@ -735,33 +823,164 @@ 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 (once M4 lands)
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
### M4 — Write — **done**
`sendChannelMessage` and `unreadCount`. (The Guestbook `join` originally listed here shipped early, with M3's `join`.)
`ConcordManager.sendChannelMessage`, `unreadCount` / `markChannelRead`, and the `revision` signal a Channel screen re-queries on.
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop.
What shipped:
### M4.5 — Community creation (optional)
- `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<Long>` (§9.5), so a Channel screen can tell that something arrived.
Owner mints `community_id`, `community_root`, `control_root`, genesis metadata (`vsk 0`) + `#general` (`vsk 2`); writes to the Control plane via the `20014` plaintext-seal path signed by `concord/control-signer`.
**Verified** with a throwaway check on the iOS target (2 cases, all passing), then deleted:
**Why consider it:** ~150 lines on top of M3, makes the feature demoable without depending on an existing Concord client, and exercises the Control-plane *write* path that join-only never touches.
- 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 ✅
**Why it might wait:** requires `control_root` handling and careful `20014` discipline.
**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.
### M5 — UI polish
**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.
`ConcordRepository` / view models / 4 screens / `BottomMenuList` entry / icon / error snackbars.
### 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.
### M6 — Cheap wins (optional)
What shipped:
Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI. Threads (`kind 1111`) are more work — separate milestone.
- **`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.
---
@@ -781,7 +1000,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
| # | 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. | Surface a backup warning on community creation. |
| 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. |
@@ -791,17 +1010,27 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
| 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 Open decision
### 13.3 Closed decision
**Community creation in v1?** See [M4.5](#m45--community-creation-optional). Recommendation: yes, after M4 — it makes the feature demoable standalone.
**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–M3,
then discarded; the tree carries no `commonTest` or `iosTest` sources for Concord. They are
**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.
@@ -817,7 +1046,7 @@ there is no regression net, so:
- 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.
### Checks that were run
### 14.1 Checks that were run
| Check | Type | Ran on |
|---|---|---|
@@ -840,8 +1069,18 @@ there is no regression net, so:
| `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 | ⏳ M3 |
| `expires_at` ms/s conversion | unit | ⏳ M3 |
| 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):
@@ -869,7 +1108,7 @@ OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d20139
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 M3 done. Nothing catches a label typo faster.
Additionally, run one **interop smoke test** against a real community before declaring the feature done. Nothing catches a label typo faster.
---
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeWidth="56"
android:strokeLineCap="round"
android:pathData="M120,220 Q120,120 220,120 L740,120 Q840,120 840,220 L840,740 Q840,840 740,840 L220,840 Q120,840 120,740 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeWidth="72"
android:strokeLineCap="round"
android:pathData="M400,320 L400,640 M560,320 L560,640 M320,400 L640,400 M320,560 L640,560" />
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M240,500 Q240,440 300,440 L660,440 Q720,440 720,500 L720,780 Q720,840 660,840 L300,840 Q240,840 240,780 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeWidth="64"
android:strokeLineCap="round"
android:pathData="M340,500 L340,440 A140,140 0 0 1 620,440 L620,500" />
</vector>
@@ -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<Screen.Home> {
HomeScreen(accountViewModel, chatViewModel)
HomeScreen(accountViewModel, chatViewModel, concordViewModel)
}
entry<Screen.RequestList> {
RequestListScreen(chatViewModel)
@@ -297,6 +315,36 @@ fun App(
entry<Screen.Settings> {
SettingsScreen(settingsViewModel)
}
entry<Screen.Communities> {
CommunitiesScreen(concordViewModel)
}
entry<Screen.JoinCommunity> { key ->
JoinCommunityScreen(concordViewModel, key.link)
}
entry<Screen.Community> { key ->
CommunityScreen(key.communityId, concordViewModel)
}
entry<Screen.Channel> { key ->
val factory = remember(key) {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChannelScreenViewModel(
key.communityId,
key.channelId,
accountRepository,
concordRepository
) as T
}
}
}
ChannelScreen(
viewModel<ChannelScreenViewModel>(
key = "${key.communityId}:${key.channelId}",
factory = factory
)
)
}
}
)
}
@@ -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,
)
@@ -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
}
@@ -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()
})
}
},
@@ -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"
)
}
}
}
}
@@ -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<ReactionGroup>,
fun MessageReactions(
emojis: List<String>,
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,
)
@@ -650,7 +650,7 @@ private fun ReplyPreview(
}
@Composable
private fun ReactionToolbar(
fun ReactionToolbar(
onReaction: (String) -> Unit
) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
@@ -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<String?>(null) }
/** The message an Edit is being written for, if any. Its text lives in [text]. */
var editing by remember { mutableStateOf<ConcordMessage?>(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<PublicKey?, Profile?> {
val profileCache = LocalProfileCache.current
val pubkey = remember(authorHex) { runCatching { PublicKey.parse(authorHex) }.getOrNull() }
val profile by remember(pubkey) {
pubkey?.let { profileCache.getMetadata(it) } ?: flowOf<Profile?>(null)
}.collectAsStateWithLifecycle(null)
return pubkey to profile
}
@@ -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)
)
},
)
}
}
}
}
},
)
}
@@ -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) + "..."
@@ -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,
)
}
}
}
},
)
}
@@ -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<InvitePreview?>(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"
@@ -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"
}
}
}
@@ -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<CommunityListEntry> = emptyList(),
val tombstones: List<CommunityListTombstone> = 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<CommunityListChannel> = emptyList(),
val relays: List<String> = 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<CommunityListFragment>.listedMemberships(): List<Membership> {
val newest = mutableMapOf<String, Pair<Long, Membership>>()
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<String, Long>()
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()
@@ -4,36 +4,7 @@ import kotlinx.serialization.decodeFromString
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Instant
/**
* Folding the Control Plane (CORD-04 §1).
*
* The Control Plane carries the Community's authoritative state as **editions**: each is a
* `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this
* edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole
* chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so.
*
* v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`).
*
* ## What v1 does not do
*
* **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the
* owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could
* publish a forged metadata or Channel edition and v1 would display it.
*
* That gap is bounded rather than open — only the owner and staff hold `control_root` (CORD-02 §2),
* and the spec itself calls that secret "a spam gate, never authority" — but it is real. It is the
* reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and
* `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly
* outranks the entity it edits.
*/
object ConcordControl {
/**
* Parses one Control rumor into an edition, or null when it is not a foldable edition.
*
* A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1
* does not implement dissolution, so treating it as an ordinary edition would misread it.
*/
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
@@ -45,7 +16,6 @@ object ConcordControl {
if (eidHex.hex32() == null) return null
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
// CORD-04: versions climb from 1. A zero version has no place in the chain.
if (version == 0uL) return null
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
@@ -63,12 +33,6 @@ object ConcordControl {
)
}
/**
* Projects the editions into the metadata and Channel list v1 renders.
*
* Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a
* broken chain on one Channel never takes the Community's metadata down with it.
*/
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
var meta: CommunityMeta? = null
val channels = mutableMapOf<String, ChannelMeta>()
@@ -90,16 +54,6 @@ object ConcordControl {
return ControlFold(meta = meta, channels = channels)
}
/**
* Picks the highest version whose `ep` chain reaches all the way back to version 1.
*
* "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down
* from the top and take the first version we can actually verify. A missing predecessor or a
* broken link disqualifies that version, not the entity.
*
* Ties on the same version break on the **lower rumor id**, never on `created_at`, so two
* clients folding the same two candidates always agree.
*/
private fun winner(group: List<ControlEdition>): ControlEdition? {
val byVersion = group
.groupBy { it.version }
@@ -8,29 +8,6 @@ import rust.nostr.sdk.Keys
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.SecretKey
/**
* Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A).
*
* Everything here is frozen by the spec, and a single wrong byte breaks interop silently
* rather than loudly — so each function quotes the CORD section that governs it, and
* `ConcordCryptoTest` pins the output against RFC 5869 vectors and independently computed
* digests. Concord ships no test vectors of its own ("Examples are illustrative, not
* verifiable test vectors").
*
* Only HMAC-SHA256 and SHA-256 come from outside: both are Okio `ByteString` members that
* are available on every target this module builds for, so no new crypto dependency is
* needed. Everything else is composition of the existing nostr SDK.
*/
/**
* HKDF-SHA256 (RFC 5869), Extract then Expand.
*
* Concord always calls this with no salt (CORD-02 A.1 specifies a zero-length salt, not 32
* zero bytes), so [salt] defaults to empty. It is exposed only so the RFC's known-answer
* vectors — which do use a salt — can be used as tests; Concord publishes none.
*
* @param length output length in octets, `1..255 * 32` per RFC 5869.
*/
fun hkdfSha256(
ikm: ByteArray,
info: ByteArray,
@@ -39,18 +16,13 @@ fun hkdfSha256(
): ByteArray {
require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" }
// Extract: PRK = HMAC-SHA256(salt, IKM). Note IKM is the HMAC *message*, not the key.
// Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen
// (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the
// two are literally the same key. Substituting is exact, not a workaround; the RFC's own
// zero-length-salt vector is asserted against it in ConcordCryptoTest.
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
val prk = ikm.toByteString().hmacSha256(saltKey)
// Expand: T(n) = HMAC-SHA256(PRK, T(n-1) | info | n), counter being one octet.
val out = Buffer()
var t = ByteString.EMPTY
var counter = 1
while (out.size < length) {
t = Buffer()
.write(t)
@@ -61,19 +33,10 @@ fun hkdfSha256(
out.write(t)
counter++
}
return out.readByteArray(length.toLong())
}
/**
* Builds the HKDF `info` for a Concord label (CORD-02 A.1):
*
* ```
* info = utf8(label) | 0x00 | id[32] | epoch_be[8] // epoch omitted for labels marked "—"
* ```
*
* [id] is always present and always 32 bytes, all-zeroes where a label has no meaningful
* id. The epoch is the only omittable field.
*/
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 {
@@ -87,23 +50,6 @@ fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
/**
* The secret-key material of a plane's group key: CORD-02 A.2's `group_key` up to and
* including A.3's `scalar_normalize`.
*
* ```
* info = hkdfInfo(label, id, epoch)
* seed = hkdf(secret, info)
* while (!isValidScalar(seed)) { info = info | counter++; seed = hkdf(secret, info) }
* ```
*
* A.3 only bites when the HKDF output is not a secp256k1 scalar, which is ~2⁻¹²⁸ rare, so
* [isValid] exists as a seam for tests; production callers pass the default.
*
* This is deliberately split from [groupKey]: it is pure byte manipulation and so is
* unit-testable, whereas the secp256k1 half needs the nostr SDK, whose native library
* cannot be loaded by a host JVM unit test (see PLAN.md §13.2).
*/
fun groupSeed(
label: String,
secret: ByteArray,
@@ -112,62 +58,47 @@ fun groupSeed(
isValid: (ByteArray) -> Boolean = ::isValidScalar,
): ByteArray {
val base = hkdfInfo(label, id, epoch)
var counter = -1 // -1 means "no counter byte", i.e. the first attempt
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++ // A.3: the counter starts at 0 on the first retry
counter++
}
error("Concord group_key: scalar_normalize exhausted for label $label")
}
/**
* A secp256k1 secret key is any integer in `[1, n-1]`, so CORD-02 A.3's validity test rejects
* exactly the all-zeroes seed and any seed not below the group order.
*/
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 // exactly n, also out of range
return false
}
private val SECP256K1_ORDER =
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
/**
* `group_key` (CORD-02 A.2): a plane's keypair, `(scalar_normalize(seed), xonly_pubkey(sk))`.
*
* The `conv_key` of A.2 needs no implementation of its own — it *is* the NIP-44 conversation
* key, which the SDK derives from the pair returned here, so nothing here hand-rolls ECDH.
*/
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())
}
/**
* `community_id` (CORD-02 A.4), which is also the community's self-certification:
*
* ```
* community_id = sha256( utf8("concord/community") | owner_xonly[32] | owner_salt[32] )
* ```
*
* Note this is a plain SHA-256 commitment with **no** `0x00` separator and no length
* prefix — it is deliberately *not* the HKDF construction of A.1, despite looking like it.
*/
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}" }
@@ -178,14 +109,6 @@ fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
}.readByteString().sha256().toByteArray()
}
/**
* `prevcommit` (CORD-02 A.8) — the commitment to the previous epoch's key, published when
* an epoch rolls so that members can verify the rotation chained from what they held:
*
* ```
* prevcommit = sha256( utf8("concord/epoch-key-commitment") | prev_epoch_be[8] | prev_key[32] )
* ```
*/
fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
return Buffer().apply {
@@ -195,22 +118,6 @@ fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
}.readByteString().sha256().toByteArray()
}
/**
* `edition_hash` (CORD-02 A.8) — links a Control edition to its predecessor, so a client
* folding the Control plane can detect a rewritten chain:
*
* ```
* edition_hash = sha256(
* len64(label) | label // label = ConcordLabel.EDITION_HASH
* | entity_id[32]
* | version_be[8]
* | (prev ? 0x01 | prev[32] : 0x00 | zero[32])
* | len64(content) | content ) // content bytes verbatim, never re-serialized
* ```
*
* [content] must be the exact bytes that were signed — re-serializing the JSON would change
* the hash.
*/
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" }
@@ -5,23 +5,6 @@ import rust.nostr.sdk.Nip19Coordinate
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.nip44Decrypt
/**
* CORD-05: redeeming an invite.
*
* Two ways to be handed the keys, one bundle:
*
* - **Public link** — `$BASE/invite/<naddr>#<fragment>`. The naddr names where the encrypted
* bundle sits on relays; the fragment carries an off-network unlock token and never reaches a
* server. The bundle is fetched, then decrypted with a key derived from the token.
* - **Direct Invite** — the same bundle, giftwrapped straight to an npub. Nothing to fetch. That
* path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`.
*
* Minting is out of v1's scope, so this file only decodes.
*
* A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the
* strength of what the bundle claims (CORD-05 §1).
*/
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
internal object ConcordRelayDictionary {
val STOCK = listOf(
@@ -67,13 +50,6 @@ 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)
/**
* Decodes a link into its locator and fragment, or null when it is not a Concord invite.
*
* The base domain is deliberately ignored — CORD-05 §2 makes the base interchangeable and says any
* client recognizing an invite must respect the naddr and fragment verbatim — so only the last path
* segment and the `#` fragment are read.
*/
fun parseInviteLink(text: String): ParsedInvite? {
val trimmed = text.trim()
val hash = trimmed.indexOf('#')
@@ -93,16 +69,6 @@ fun parseInviteLink(text: String): ParsedInvite? {
)
}
/**
* CORD-05 §3's fragment layout:
*
* ```
* [version=4][flags][relays?][token:16]
* ```
*
* With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries,
* each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL.
*/
fun decodeInviteFragment(fragment: String): InviteFragment? {
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
// Two header bytes plus the token at minimum.
@@ -112,8 +78,7 @@ fun decodeInviteFragment(fragment: String): InviteFragment? {
if (reader.byte() != FRAGMENT_VERSION) return null
val flags = reader.byte() ?: return null
// The stock flag selects the whole dictionary, so nothing extra is carried and the cap on
// explicit entries does not apply to it.
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
ConcordRelayDictionary.STOCK
} else {
@@ -142,41 +107,24 @@ fun decodeInviteFragment(fragment: String): InviteFragment? {
)
}
/**
* `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one
* thing — decrypting the bundle — and nothing else.
*
* Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's
* `nip44_encrypt(bundle_key, …)` is the same self-ECDH conversation key every other Concord
* `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key
* is what lets the existing SDK do the encryption instead of hand-rolling NIP-44.
*/
fun inviteBundleKey(tokenHex: String): GroupKey? =
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
?: return null
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
}
/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
/**
* CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so
* the preview can explain itself rather than silently refusing.
*
* The first one is the load-bearing one: `community_id == sha256("concord/community" ‖ owner ‖
* owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community.
*/
fun CommunityInvite.problems(): List<String> {
val problems = mutableListOf<String>()
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"
@@ -186,12 +134,14 @@ fun CommunityInvite.problems(): List<String> {
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"
@@ -200,20 +150,13 @@ fun CommunityInvite.problems(): List<String> {
return problems
}
/** CORD-05 §1: an expired bundle still previews, but joining refuses. */
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
/**
* The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays,
* used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the
* bundle's copy is the join-time snapshot of the real set (CORD-02 §6).
*/
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
val source = relays.ifEmpty { fallback }
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
}
/** Turns a validated bundle into the membership we persist. */
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
communityId = communityId.lowercase(),
owner = owner.lowercase(),
@@ -230,14 +173,11 @@ fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
key = channel.key.lowercase(),
epoch = channel.epoch,
name = channel.name,
// A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the
// only reading the bundle supports. The Control fold overrides this for display.
private = !channel.key.equals(communityRoot, ignoreCase = true),
)
},
)
/** Reads the fragment's length-prefixed fields, refusing to run past the end. */
private class FragmentReader(private val bytes: ByteArray) {
var index = 0
private set
@@ -1,21 +1,5 @@
package su.reya.coop.concord
/**
* Frozen constants from the Concord specification.
*
* Concord is defined by the CORD documents (github.com/concord-protocol/concord); the
* numbers, labels and permission bits below are normative and must match byte for byte
* or nothing interoperates. They are collected in this one file so that a spec revision
* is a one-file change and so that no literal kind number ever appears at a call site.
*
* References are to the CORD section that defines each value. See `PLAN.md` Appendix A
* for the same tables with prose.
*/
/**
* Event kinds, either the outer wrap or the inner rumor it carries (CORD-01, CORD-02 §5,
* CORD-02 Appendix B).
*/
object ConcordKind {
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
const val WRAP = 1059
@@ -86,6 +70,12 @@ object ConcordKind {
/** 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
}
@@ -129,10 +119,6 @@ object ConcordVsk {
const val PIN_LIST = 11
}
/**
* Permission bits (CORD-04 §3). Rank ordering is separate: `position` orders authority and
* **lower is higher**, with the owner at position 0.
*/
object ConcordPermission {
const val MANAGE_ROLES = 1 shl 0
const val MANAGE_CHANNELS = 1 shl 1
@@ -148,12 +134,8 @@ object ConcordPermission {
const val VIEW_AUDIT_LOG = 1 shl 8
const val MENTION_EVERYONE = 1 shl 9
// 1 shl 10 is reserved.
const val PIN_MESSAGES = 1 shl 11
// 1 shl 12 is reserved.
/**
* 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.
@@ -162,10 +144,6 @@ object ConcordPermission {
BAN or CREATE_INVITE or PIN_MESSAGES
}
/**
* HKDF label registry (CORD-02 Appendix A.6). The label is the *first* field of the HKDF
* `info` (see `hkdfInfo`), so these strings are on the wire and must not be edited.
*/
object ConcordLabel {
/** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
const val CHANNEL = "concord/channel"
@@ -250,6 +228,9 @@ object ConcordTag {
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
const val QUOTE = "q"
/** `["e", "<rumor id>"]` — the message a reaction, edit or delete targets (CORD-03 §2.3–2.5). */
const val E = "e"
/** `["vsk", "<n>"]` — Control edition entity type. */
const val VSK = "vsk"
@@ -277,6 +258,6 @@ object ConcordTag {
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
const val INVITE = "invite"
/** `["k", "3313"]` — the one deliberate outer-tag exception on a Direct Invite wrap. */
/** `["k", "<kind>"]` — the target's kind on a reaction, the invite hint on a `3313` wrap. */
const val K = "k"
}
@@ -5,9 +5,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.decodeFromString
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
@@ -22,33 +22,7 @@ import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
/**
* Concord's read path: which planes we hold keys for, what we fold out of them, and how an invite
* turns into membership.
*
* ## Why this lives on [Nostr]
*
* The client has exactly **one** notification pump. `client.notifications()` is called once, inside
* [Nostr.handleNotifications], and a second consumer would silently split the stream — so Concord
* routes through that pump rather than subscribing to its own (see [isPlaneAddress] and [onInboxRumor]).
*
* ## The two phases
*
* [restore] is local and fast: it reads memberships from storage and folds whatever the Control
* plane has already cached, so routing is meaningful before anything hits the network. [sync] is
* the network half: connect the Community's relays and subscribe to its plane addresses. The pump
* calls them in that order, so the first event that arrives already knows where it belongs.
*/
class ConcordManager(private val nostr: Nostr) {
/**
* Concord's plane pubkeys, keyed by hex. Every incoming `kind 1059` is routed by author: a
* Concord wrap is signed by a plane's *derived* stream key and can never be read by the NIP-17
* path, which assumes an ephemeral author and a `p`-tagged recipient.
*
* Replaced wholesale rather than mutated, and read from the notification pump's thread while a
* worker coroutine rewrites it, so the reference is volatile.
*/
@Volatile
private var planes: Map<String, Plane> = emptyMap()
@@ -56,6 +30,21 @@ class ConcordManager(private val nostr: Nostr) {
@Volatile
private var folds: Map<String, ControlFold> = 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<String, Int> = 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<Long> = _revision.asStateFlow()
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
@@ -66,6 +55,13 @@ class ConcordManager(private val nostr: Nostr) {
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
val directInvites: StateFlow<List<CommunityInvite>> = _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<Boolean> = _restored.asStateFlow()
private var store: ConcordStore? = null
/**
@@ -79,10 +75,6 @@ class ConcordManager(private val nostr: Nostr) {
store = ConcordStore(storage, nostr)
}
// -----------------------------------------------------------------------------------------
// Routing
// -----------------------------------------------------------------------------------------
/** True when a wrap's author is one of our planes' stream keys. */
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
@@ -99,6 +91,12 @@ class ConcordManager(private val nostr: Nostr) {
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.
@@ -117,7 +115,8 @@ class ConcordManager(private val nostr: Nostr) {
fun onInboxRumor(rumor: UnsignedEvent): Boolean {
if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
val invite = runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
val invite =
runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
if (invite == null) {
println("Concord: a direct invite arrived but its bundle could not be read")
return true
@@ -128,16 +127,17 @@ class ConcordManager(private val nostr: Nostr) {
}
_directInvites.update { current ->
if (current.any { it.communityId.equals(invite.communityId, ignoreCase = true) }) current
if (current.any {
it.communityId.equals(
invite.communityId,
ignoreCase = true
)
}) current
else current + invite
}
return true
}
// -----------------------------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------------------------
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
suspend fun restore() {
val store = store ?: return
@@ -154,10 +154,37 @@ class ConcordManager(private val nostr: Nostr) {
)
}
refreshPlanes()
_restored.value = true
}
/** Network half of startup: connect every Community's relays and subscribe to its planes. */
/**
* 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)
@@ -169,9 +196,97 @@ class ConcordManager(private val nostr: Nostr) {
}
}
// -----------------------------------------------------------------------------------------
// Invites
// -----------------------------------------------------------------------------------------
/**
* 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<CommunityListFragment>(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
@@ -179,16 +294,19 @@ class ConcordManager(private val nostr: Nostr) {
* (CORD-05 §1: a bundle is passive until the user accepts).
*/
suspend fun previewInvite(link: String): InvitePreview {
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 client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
// The fragment's bootstrap relays only have to *find* the bundle; the bundle then carries
// the Community's real relay set (CORD-05 §3).
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 ->
@@ -202,7 +320,10 @@ class ConcordManager(private val nostr: Nostr) {
.identifier(parsed.identifier)
val bundle = client
.fetchEvents(ReqTarget.manual(relays.associateWith { listOf(filter) }), timeout = 8.seconds)
.fetchEvents(
ReqTarget.manual(relays.associateWith { listOf(filter) }),
timeout = 8.seconds
)
.toVec()
.firstOrNull()
?: throw IllegalStateException("No invite bundle was found at that link")
@@ -218,7 +339,18 @@ class ConcordManager(private val nostr: Nostr) {
}
/** 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())
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 {
@@ -228,40 +360,158 @@ class ConcordManager(private val nostr: Nostr) {
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
val updated =
_memberships.value.filterNot { it.communityId == membership.communityId } + membership
store.saveMemberships(updated)
_memberships.value = updated
refreshPlanes()
subscribeCommunity(membership)
// CORD-02 §5: a Join is each member's own word, published to the Guestbook. There is no
// Guestbook fold in v1 — the Control plane is what drives the UI.
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())
}
// -----------------------------------------------------------------------------------------
// Reading
// -----------------------------------------------------------------------------------------
/** A Channel's messages, oldest first, with reactions and edits folded on top. */
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
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)
}
/**
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
* keeps it current.
* 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 channelMessages(channelIdHex: String): List<ConcordMessage> =
store?.cachedRumors(channelIdHex).orEmpty()
.mapNotNull { it.toConcordMessage() }
.sortedBy { it.timestampMs }
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")
// -----------------------------------------------------------------------------------------
// Planes
// -----------------------------------------------------------------------------------------
/**
* 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<Tag> = 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.
@@ -281,14 +531,10 @@ class ConcordManager(private val nostr: Nostr) {
val granted = membership.channels.associateBy { it.id }
if (communityId != null && root != null) {
// Control is write-restricted: we hold the read key plus the writers' pubkey, which
// is all reading takes (CORD-01, CORD-02 §5). A bundle with no `control_pk` is a
// legacy, pre-split Community, whose plane was addressed by the `concord/control`
// derivation itself; v1 does not read those, so such a Community shows no metadata.
// CORD-02 §5 requires that legacy reading and the first base rotation upgrades it.
membership.controlPk?.let { controlPkHex ->
if (controlPkHex.hex32() != null) {
val key = controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
val key =
controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
next[key.streamPublicKeyHex] = Plane(
communityIdHex = membership.communityId,
role = PlaneRole.Control,
@@ -314,8 +560,6 @@ class ConcordManager(private val nostr: Nostr) {
val stored = granted[channelIdHex]
val channelId = channelIdHex.hex32()
// A Public Channel's key *is* the community_root, so one we were never granted is
// still derivable; a Private one is not (CORD-03 §1).
val secret = stored?.key?.hex32()
?: if (meta?.isPrivate != true) root else null
val epoch = stored?.epoch ?: membership.rootEpoch
@@ -337,6 +581,9 @@ class ConcordManager(private val nostr: Nostr) {
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,
)
}
@@ -361,11 +608,13 @@ class ConcordManager(private val nostr: Nostr) {
/** 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) }
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) }
_memberships.value.firstOrNull { it.communityId == communityIdHex }
?.let { subscribeCommunity(it) }
}
}
@@ -381,12 +630,12 @@ class ConcordManager(private val nostr: Nostr) {
val id = subscriptionId(membership.communityId)
client.unsubscribe(id)
val relays = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }.distinct()
val relays = membership.relays
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
.distinct()
if (relays.isEmpty()) return
// Always the Community's own relays, never the app's defaults: Concord reverses NIP-59
// (fixed author, ephemeral `p`), so a relay enforcing the optional `p`-tag guard drops
// these wraps, and the bootstrap set is tuned for NIP-17.
relays.forEach { relay ->
client.addRelay(relay)
client.connectRelay(relay)
@@ -396,7 +645,10 @@ class ConcordManager(private val nostr: Nostr) {
.filter { it.communityIdHex == membership.communityId }
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
.distinct()
.map { Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it)) }
.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)
@@ -407,14 +659,15 @@ class ConcordManager(private val nostr: Nostr) {
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 author =
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch)
// CORD-05 §1: an accepting joiner echoes the invite's creator and label, which is what
// makes per-link usage counters possible at all.
val extraTags = invite?.creatorNpub?.let { creator ->
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
}.orEmpty()
val extraTags = invite?.creatorNpub
?.let { creator ->
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
}.orEmpty()
val rumor = plane.rumor(
author = author,
@@ -425,11 +678,18 @@ class ConcordManager(private val nostr: Nostr) {
)
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())
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 subscriptionId(communityIdHex: String): String =
"$SUBSCRIPTION_PREFIX$communityIdHex"
private fun preview(
invite: CommunityInvite,
@@ -437,14 +697,21 @@ class ConcordManager(private val nostr: Nostr) {
fallbackRelays: List<String>,
): InvitePreview {
val relays = invite.relaySet(fallbackRelays)
val problems = invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
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) },
alreadyJoined = _memberships.value.any {
it.communityId.equals(
invite.communityId,
ignoreCase = true
)
},
)
}
@@ -6,35 +6,11 @@ import kotlinx.serialization.json.Json
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Instant
/**
* Concord's data model: what we persist about a Community, what an invite carries, and what the
* Control fold projects.
*
* Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon])
* keep the spec's snake_case field names because those keys are normative. Types that are ours
* alone ([Membership], [StoredChannelKey]) keep Kotlin naming.
*
* Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to
* round-trip fields they do not understand. v1 only ever *reads* these documents, so the models
* below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing
* them back must preserve what it did not parse.
*/
internal val concordJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// ---------------------------------------------------------------------------------------------
// Persisted
// ---------------------------------------------------------------------------------------------
/**
* A Community this device has joined, as stored locally. Holding these keys *is* membership
* (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store.
*
* There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key
* cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise.
*/
@Serializable
data class Membership(
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
@@ -59,10 +35,6 @@ data class Membership(
val channels: List<StoredChannelKey> = emptyList(),
)
/**
* A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry
* it here, which is why [private] can only be a hint — the Control fold is the authority.
*/
@Serializable
data class StoredChannelKey(
val id: String,
@@ -72,18 +44,6 @@ data class StoredChannelKey(
val private: Boolean = false,
)
// ---------------------------------------------------------------------------------------------
// Invite bundle (CORD-05 §1)
// ---------------------------------------------------------------------------------------------
/**
* The `CommunityInvite` bundle: the same document whether it arrives inside a public link's
* encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6).
*
* The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real
* Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will
* never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it.
*/
@Serializable
data class CommunityInvite(
@SerialName("community_id") val communityId: String = "",
@@ -111,10 +71,6 @@ data class InviteChannel(
val name: String? = null,
)
/**
* A pointer to an encrypted blob — icon, banner (CORD-02 §6). The media server holds ciphertext
* only; a member fetches, decrypts and verifies [hash]. v1 never fetches these.
*/
@Serializable
data class ConcordIcon(
val url: String? = null,
@@ -123,10 +79,6 @@ data class ConcordIcon(
val hash: String? = null,
)
// ---------------------------------------------------------------------------------------------
// Control fold
// ---------------------------------------------------------------------------------------------
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
@Serializable
data class CommunityMeta(
@@ -149,13 +101,6 @@ data class ChannelMeta(
val deleted: Boolean = false,
)
/**
* One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B).
*
* The tags are the edition machinery — `vsk` names the entity type, `eid` its stable coordinate,
* `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as
* a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization.
*/
data class ControlEdition(
val vsk: Int,
val eidHex: String,
@@ -173,10 +118,6 @@ data class ControlFold(
val channels: Map<String, ChannelMeta>,
)
// ---------------------------------------------------------------------------------------------
// Read surface
// ---------------------------------------------------------------------------------------------
/** 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,
@@ -185,6 +126,11 @@ data class ConcordChannel(
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. */
@@ -195,6 +141,12 @@ data class CommunityState(
val channels: List<ConcordChannel>,
)
/** 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<String>,
)
/** A Chat-plane message, already unwrapped and signature-checked. */
data class ConcordMessage(
val idHex: String,
@@ -203,6 +155,9 @@ data class ConcordMessage(
val createdAt: Instant,
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
val timestampMs: Long,
val reactions: List<ConcordReaction> = 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). */
@@ -220,18 +175,8 @@ data class InvitePreview(
val joinable: Boolean get() = problems.isEmpty() && !expired
}
// ---------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------
/**
* CORD-05 §1: the bundle's `expires_at` is unix **milliseconds**, the Invite List's is unix
* **seconds**, and a NIP-40 `expiration` tag is **seconds**. Three spellings of one instant, so
* every conversion lives here. Returns null when the bundle carries no expiry at all.
*/
fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
/** CORD-02 §4: true time is `created_at * 1000 + ms`; an absent or out-of-range `ms` reads as 0. */
internal fun UnsignedEvent.concordTimestampMs(): Long {
val ms = tags().toVec()
.firstOrNull { it.kind() == ConcordTag.MS }
@@ -241,7 +186,6 @@ internal fun UnsignedEvent.concordTimestampMs(): Long {
return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
}
/** Projects a Chat-plane rumor into a [ConcordMessage], or null when it is not a message. */
internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
return ConcordMessage(
@@ -252,3 +196,51 @@ internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
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<UnsignedEvent>.toChannelMessages(): List<ConcordMessage> {
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()
@@ -15,85 +15,23 @@ import rust.nostr.sdk.nip44Decrypt
import rust.nostr.sdk.nip44Encrypt
import kotlin.time.Instant
/**
* The CORD-01 wire stack: `wrap → seal → rumor`.
*
* A stream is a shared key and a stream of gift wraps signed with it. Everything a plane sends
* is three nested layers:
*
* ```
* wrap kind 1059, signed by the stream key, one ephemeral `p` tag
* └ nip44(conv_key) of
* seal kind 20013 (encrypted) or 20014 (plaintext), signed by the real author
* └ nip44(conv_key) of | byte-verbatim
* rumor unsigned, its authority is the seal's signature around it
* ```
*
* Both encrypted layers use the *same* conversation key — that is double encryption under one
* key, not two, and it is what makes the wrap readable by anyone holding the plane key.
*
* Concord reverses NIP-59: the author is fixed (the stream key) and the `p` tag is ephemeral,
* which is why the app's normal NIP-59 path in `MessageManager.extractRumor` cannot read these
* and why routing happens by author before that code is reached.
*/
/**
* Which of CORD-01's two seal forms a plane uses; CORD-02 §5 makes this a fixed property of the
* plane, "never a per-message choice". Because the plane owns it, [wrap] cannot pick the wrong
* one and [unwrap] can reject a seal of the other form instead of quietly accommodating it.
*/
enum class SealForm(private val kindValue: Int) {
/**
* Chat, Guestbook, rekey. The rumor is NIP-44-encrypted inside the already-encrypted wrap,
* so no relay — honest or malicious — can retain and display the rumor as a public event.
*/
Encrypted(ConcordKind.SEAL),
/**
* Control plane only. The seal's content is the rumor's serialized JSON **byte-verbatim**,
* because a signature over ciphertext is bound to the key that encrypted it and would break
* if re-wrapped under another key across an epoch change.
*/
Plaintext(ConcordKind.PLAINTEXT_SEAL);
val kind: UShort get() = kindValue.toUShort()
}
/**
* CORD-03 §3: what a Chat-plane rumor must commit so a member cannot re-wrap another's message
* into a different Channel or replay it across an epoch.
*
* The tags live *inside* the author-signed rumor, so the author's signature covers them; the
* reader checks them strict-equal against the coordinate whose key opened the wrap.
*/
data class ChatBinding(val channelIdHex: String, val epoch: ULong)
/**
* One plane's keys and, for a Chat plane, the coordinate its rumors are bound to.
*
* [read] decrypts: it is the conversation key for the wrap and for an encrypted seal. [stream]
* is the address that signs wraps. They are the same key on a normal stream, and differ only on
* a write-restricted one (CORD-01, used by the Control Plane in CORD-02 §5) — where a reader
* holds the read key plus the writers' *pubkey*, enough to verify a wrap but not to mint one.
*
* Deliberately not a `data class`: there is no value equality worth having, and the default
* `toString` keeps key material out of logs.
*/
class PlaneKey(
val read: GroupKey,
val stream: GroupKey?,
/** The x-only hex of the key every wrap on this plane must be signed by. */
val streamPublicKeyHex: String,
/** CORD-02 §5: the one seal form this plane may use, on both the write and the read side. */
val form: SealForm,
val chat: ChatBinding?,
)
/**
* A Channel's Chat plane (CORD-03 §1). Public channels pass `community_root` as the secret,
* Private ones their own independent `channel_key`; the `channel_id` in the derivation is what
* gives each Channel a distinct address either way.
*/
fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
return PlaneKey(
@@ -105,7 +43,6 @@ fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong
)
}
/** The community-wide Guestbook plane (CORD-02 §5), where joins, leaves and kicks are recorded. */
fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
return PlaneKey(
@@ -117,12 +54,6 @@ fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: U
)
}
/**
* The Control plane's **read** key (CORD-02 §5). Its wraps are signed by the staff-held
* `control-signer` key instead, so this plane is read-only and [streamPublicKeyHex] is
* whatever the invite claimed — nothing in an invite can prove it, so build nothing
* security-relevant on it beyond the subscription address.
*/
fun controlPlaneKey(
communityRoot: ByteArray,
communityId: ByteArray,
@@ -139,17 +70,6 @@ fun controlPlaneKey(
)
}
/**
* Builds an unsigned rumor for this plane (CORD-01).
*
* The `channel`/`epoch` binding tags are stamped from the plane itself, and therefore from the
* very key that will encrypt the wrap, so a rumor can never be built whose coordinate does not
* match the key it travels under. `ms` rides every rumor (CORD-02 A.5) because `created_at` is
* never tweaked — true time is `created_at * 1000 + ms`.
*
* A rumor is never signed and never a standalone artifact: its authority is the seal's
* signature around it.
*/
fun PlaneKey.rumor(
author: PublicKey,
kind: UShort,
@@ -172,47 +92,28 @@ fun PlaneKey.rumor(
.ensureId()
}
/**
* Wraps [rumor] into a publishable stream event.
*
* [signer] signs the *seal*, so it must be the real author's signer — the user's own key, which
* also means a NIP-46 bunker works here. The wrap is signed by the plane's stream key instead,
* and the seal form comes from the plane itself (CORD-02 §5).
*
* Both the seal and the wrap take the rumor's `created_at`, never a fresh one, so the three
* layers agree and pagination by wrap timestamp lines up with message ordering.
*/
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),
// CORD-01: byte-verbatim for a plaintext seal, so a re-wrap can carry the exact
// signed bytes forward instead of re-serializing them.
if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson,
)
.customCreatedAt(createdAt)
.finalizeAsync(signer)
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)
}
// The receiver drops a rumor whose author differs from its seal's, so publishing a mismatch
// would produce a message nobody can read. Fail here instead, where the cause is visible.
check(seal.author() == rumor.author()) {
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${rumor.author().toHex()}"
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${
rumor.author().toHex()
}"
}
return try {
// The ephemeral `p` is discarded: it only breaks linkage between a plane's wraps. Only
// its pubkey is kept, and `Tag.publicKey` has already serialized it, so destroying the
// keypair right away is safe. v1 has no giftwrap deletion, which is the one thing
// CORD-01 §Deletions would want the secret for.
val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
Keys(stream.secretKey).use { keys ->
EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
@@ -227,19 +128,8 @@ suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event
}
}
/**
* Unwraps a stream event and enforces every check a reader must make, returning null when any
* of them fails.
*
* Null means **drop**, never retry: a wrong key, a forged or unverifiable signature, an
* impersonation attempt, a decompression/serialization failure, or a `channel`/`epoch`
* mismatch (CORD-03 §3). The checks are all here so a caller cannot skip one, and nothing is
* rendered before they pass.
*/
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
// The wrap is signed by the stream key. Verifying this is what makes CORD-01's
// write-restricted split real: a read-key holder can verify a wrap but cannot mint one.
if (event.author().toHex() != streamPublicKeyHex) return null
if (!event.verify()) return null
@@ -248,8 +138,6 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
}.getOrNull() ?: return null
if (!seal.verify()) return null
// CORD-02 §5 makes the seal form a fixed property of the plane, so a seal of the other form
// is a discipline violation, not a variant to accommodate: only the matching pair is accepted.
val sealKind = seal.kind().asU16()
val rumorJson = when {
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
@@ -260,19 +148,14 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
else -> return null
}
val rumor = runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: 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
}
/**
* CORD-03 §3, strict-equal and fail-closed: on a Chat plane both tags must be present and match
* this plane's coordinate, 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 this is a no-op.
*/
private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
val expected = chat ?: return true
val tags = rumor.tags().toVec()
@@ -281,20 +164,9 @@ private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
return channel == expected.channelIdHex && epoch == expected.epoch.toString()
}
/**
* CORD-02 A.5: true time is `created_at * 1000 + ms`, and a reader drops a rumor whose `ms`
* falls outside `0..999`. `mod` rather than `%` so a pre-epoch timestamp cannot produce a
* negative value.
*/
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
/**
* NIP-44's plaintext cap, enforced by the publisher (CORD-01 §Encoding).
*
* Libraries are lenient and a lenient publisher mints events a strict reader cannot decrypt, so
* this fails loudly at build time instead of producing an undecryptable message.
*/
private const val MAX_PLAINTEXT_BYTES = 65_535
private fun concordKind(kind: Int): UShort = kind.toUShort()
private fun nip44Seal(key: GroupKey, plaintext: String): String {
val size = plaintext.encodeToByteArray().size
@@ -304,5 +176,4 @@ private fun nip44Seal(key: GroupKey, plaintext: String): String {
return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
}
/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */
private fun concordKind(kind: Int): UShort = kind.toUShort()
private const val MAX_PLAINTEXT_BYTES = 65_535
@@ -1,8 +1,6 @@
package su.reya.coop.concord
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Keys
@@ -13,19 +11,7 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.AppStorage
import su.reya.coop.nostr.Nostr
/**
* Concord's local persistence, in the two places the rest of the app already keeps things.
*
* **Secrets → [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2),
* so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go
* through plaintext storage, and they never touch LMDB.
*
* **Community state → LMDB, as index events.** Decrypted plane rumors are cached the same way
* [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the
* Control fold has something to fold before the network answers.
*/
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
suspend fun loadMemberships(): List<Membership> {
val raw = try {
storage.getSecret(MEMBERSHIPS_KEY)
@@ -54,27 +40,28 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
}
}
/**
* Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the
* community id for Control and Guestbook.
*
* The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable*
* kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every
* message would replace the one before it and history would vanish silently. The wrap id is
* that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it —
* and it doubles as the dedupe key when a wrap arrives from several relays.
*/
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())),
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())
.finalizeAsync(Keys.generate())
nostr.client?.database()?.saveEvent(event)
} catch (e: CancellationException) {
@@ -99,14 +86,16 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
}
return events
.mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() }
.mapNotNull {
runCatching {
UnsignedEvent.fromJson(it.content()).ensureId()
}.getOrNull()
}
.sortedBy { it.createdAt().asSecs() }
}
private companion object {
const val MEMBERSHIPS_KEY = "concord_memberships"
/** Single-letter index tags; `r` is what `Filter.reference` queries. */
const val INDEX_SCOPE_TAG = "r"
const val INDEX_KIND_TAG = "k"
}
@@ -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<List<CommunityState>> = concord.communities
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
val directInvites: StateFlow<List<CommunityInvite>> = concord.directInvites
/** True once memberships have been loaded, so an empty list means "joined nothing". */
val isReady: StateFlow<Boolean> = concord.restored
/** Bumped whenever a plane rumor lands, so an open Channel knows to re-read its history. */
val revision: StateFlow<Long> = 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<ConcordMessage> =
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 <T> attempt(block: suspend () -> T): T? = withContext(defaultDispatcher) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
null
}
}
}
@@ -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<Profile?> = accountRepository.currentUserProfile
val channel: StateFlow<ConcordChannel?> = 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<ConcordMessage>()
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) }
}
}
@@ -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<List<CommunityState>> = repository.communities
val directInvites: StateFlow<List<CommunityInvite>> = repository.directInvites
val isReady: StateFlow<Boolean> = 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()
}