Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4eb697a4 | ||
|
|
5b72b07ca8 | ||
|
|
63ef074e8f | ||
|
|
9230628441 |
@@ -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.
|
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).
|
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
|
## 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` |
|
| **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. |
|
| **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. |
|
| **05** Invites | **Redeem only**: Direct Invite (`3313`) + public link bundle (`33301`) + fragment decoder. No minting. |
|
||||||
|
|
||||||
### 3.2 Deferred — explicitly out of v1
|
### 3.2 Deferred — explicitly out of v1
|
||||||
|
|
||||||
| CORD / feature | Why deferred |
|
| 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. |
|
| **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. |
|
| **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. |
|
| **05** minting | Invite List (`13303`), Registry (`vsk 8`), revocation tombstones. |
|
||||||
| **07** A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. |
|
| **07** A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. |
|
||||||
| **08** Disappearing | Cheap to add later; `Tag.expiration` exists. |
|
| **08** Disappearing | Cheap to add later; `Tag.expiration` exists. |
|
||||||
| Pins / Edits / Threads / WebXDC | Not needed for a first cut. |
|
| Pins / Threads / WebXDC | Not needed for a first cut. |
|
||||||
| Community List (`33302`) | Cross-device sync; single-device + `AppStorage` covers v1. |
|
| 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. |
|
| 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.
|
> **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` |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `ConcordManager.kt` | Subscriptions, relay connect, notification routing, send-message, join/leave |
|
||||||
|
|
||||||
### 5.2 New — `shared/src/commonMain/kotlin/su/reya/coop/`
|
### 5.2 New — `shared/src/commonMain/kotlin/su/reya/coop/`
|
||||||
|
|
||||||
| File | Contents |
|
**Delivered in M5.**
|
||||||
|---|---|
|
|
||||||
| `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/`
|
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `CommunitiesScreen.kt` | Joined communities list + FAB → Join |
|
| `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 |
|
||||||
| `CommunityScreen.kt` | Channel list for one community |
|
| `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 |
|
||||||
| `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join |
|
| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel`. `previewInvite` / `join` stay **suspend** so the Join screen owns its spinner |
|
||||||
| `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states |
|
| `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**
|
### 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.
|
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 |
|
| 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/.../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 |
|
Wired in M5:
|
||||||
| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` |
|
|
||||||
| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList` |
|
| File | Change |
|
||||||
| `composeApp/src/androidMain/composeResources/drawable/ic_communities.xml` | icon |
|
|---|---|
|
||||||
|
| `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/guestbook` | `community_root` | `community_id` | yes |
|
||||||
| `concord/dissolved` | `community_id` | `0…0` | — |
|
| `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)
|
### 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).
|
**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
|
## 10. Invites
|
||||||
@@ -633,15 +718,18 @@ If a deep link is wanted later, add `coop://invite?url=<urlencoded>` and let Coo
|
|||||||
|
|
||||||
## 11. UI
|
## 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 |
|
| Screen | Mirrors | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CommunitiesScreen` | `HomeScreen` list + FAB | Community avatar (`Avatar`), name, channel count. Empty state per `ContactListScreen` convention. |
|
| `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 icon on private. `#general` comes from genesis. |
|
| `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` | Reuse `DateSeparator`, `ChatInput`, `Avatar`. Discord-style (author shown per message) rather than Coop's DM style. |
|
| `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. |
|
| `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
|
### 11.1 Wiring, following existing conventions
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
@@ -666,8 +754,8 @@ And one entry in `BottomMenuList` (`HomeScreen.kt:785-791`) plus `ic_communities
|
|||||||
|
|
||||||
### 11.2 UI honesty
|
### 11.2 UI honesty
|
||||||
|
|
||||||
- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community settings/info row.
|
- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community info row. Delivered as `BetaNotice` on `CommunityScreen`.
|
||||||
- Warn on community creation (if implemented) that losing the owner key kills the community — there is no succession by design.
|
- 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`
|
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
|
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
|
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 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.
|
**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 |
|
| # | 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 14. Verification plan
|
||||||
|
|
||||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M3,
|
**No test files are kept** (decision, M2). The checks below were written and run during M1–M6,
|
||||||
then discarded; the tree carries no `commonTest` or `iosTest` sources for Concord. They are
|
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
|
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.
|
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
|
- 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.
|
`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 |
|
| Check | Type | Ran on |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -840,8 +1069,18 @@ there is no regression net, so:
|
|||||||
| `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 |
|
| `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 |
|
||||||
| `20013` vs `20014` discipline, both directions | 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 |
|
| 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 |
|
| 32 bytes → base64url = 43 chars, unpadded | unit | iOS ✅ M3 |
|
||||||
| `expires_at` ms/s conversion | unit | ⏳ 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):
|
**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
|
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.
|
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 kotlinx.coroutines.launch
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
|
import su.reya.coop.repository.ConcordRepository
|
||||||
import su.reya.coop.repository.SettingsRepository
|
import su.reya.coop.repository.SettingsRepository
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
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.SettingsScreen
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
import su.reya.coop.screens.chat.ChatScreen
|
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.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
||||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodel.ConcordViewModel
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import su.reya.coop.viewmodel.SettingsViewModel
|
import su.reya.coop.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
@@ -90,6 +97,7 @@ fun App(
|
|||||||
profileCache: ProfileCache,
|
profileCache: ProfileCache,
|
||||||
accountRepository: AccountRepository,
|
accountRepository: AccountRepository,
|
||||||
chatRepository: ChatRepository,
|
chatRepository: ChatRepository,
|
||||||
|
concordRepository: ConcordRepository,
|
||||||
settingsRepository: SettingsRepository,
|
settingsRepository: SettingsRepository,
|
||||||
connectivityMonitor: ConnectivityMonitor,
|
connectivityMonitor: ConnectivityMonitor,
|
||||||
) {
|
) {
|
||||||
@@ -109,6 +117,10 @@ fun App(
|
|||||||
settingsRepository
|
settingsRepository
|
||||||
)
|
)
|
||||||
|
|
||||||
|
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(
|
||||||
|
concordRepository
|
||||||
|
)
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -120,6 +132,7 @@ fun App(
|
|||||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||||
|
val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
@@ -170,6 +183,11 @@ fun App(
|
|||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
launch {
|
||||||
|
concordViewModel.errorEvents.collect { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
launch {
|
launch {
|
||||||
profileCache.errorEvents.collect { message ->
|
profileCache.errorEvents.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
@@ -237,7 +255,7 @@ fun App(
|
|||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Home> {
|
entry<Screen.Home> {
|
||||||
HomeScreen(accountViewModel, chatViewModel)
|
HomeScreen(accountViewModel, chatViewModel, concordViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.RequestList> {
|
entry<Screen.RequestList> {
|
||||||
RequestListScreen(chatViewModel)
|
RequestListScreen(chatViewModel)
|
||||||
@@ -297,6 +315,36 @@ fun App(
|
|||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsScreen(settingsViewModel)
|
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.nostr.NostrManager
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
|
import su.reya.coop.repository.ConcordRepository
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.repository.SettingsRepository
|
import su.reya.coop.repository.SettingsRepository
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
@@ -52,6 +53,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val concordRepository by lazy {
|
||||||
|
ConcordRepository(NostrManager.instance, scope)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
throwable.printStackTrace()
|
throwable.printStackTrace()
|
||||||
@@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
profileCache = profileCache,
|
profileCache = profileCache,
|
||||||
accountRepository = accountRepository,
|
accountRepository = accountRepository,
|
||||||
chatRepository = chatRepository,
|
chatRepository = chatRepository,
|
||||||
|
concordRepository = concordRepository,
|
||||||
settingsRepository = settingsRepository,
|
settingsRepository = settingsRepository,
|
||||||
connectivityMonitor = connectivityMonitor,
|
connectivityMonitor = connectivityMonitor,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,4 +61,16 @@ sealed interface Screen : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen
|
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.RoomUiState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.ago
|
||||||
|
import su.reya.coop.concord.parseInviteLink
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.uiStateFlow
|
import su.reya.coop.uiStateFlow
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodel.ConcordViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
chatViewModel: ChatViewModel
|
chatViewModel: ChatViewModel,
|
||||||
|
concordViewModel: ConcordViewModel,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
@@ -166,16 +169,22 @@ fun HomeScreen(
|
|||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching { PublicKey.parse(result) }
|
// A Concord invite is a link, not a npub, so it is routed to the Join screen instead of
|
||||||
.onSuccess { pubkey ->
|
// being force-fed to PublicKey.parse.
|
||||||
try {
|
if (parseInviteLink(result) != null) {
|
||||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
navigator.navigate(Screen.JoinCommunity(result))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
} else {
|
||||||
} catch (e: Exception) {
|
runCatching { PublicKey.parse(result) }
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
.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
|
// Clear the nav state
|
||||||
qrScanResult.clear()
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
@@ -473,7 +482,8 @@ fun HomeScreen(
|
|||||||
BottomMenuList(
|
BottomMenuList(
|
||||||
onDismiss = dismissAndRun,
|
onDismiss = dismissAndRun,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
chatViewModel = chatViewModel
|
chatViewModel = chatViewModel,
|
||||||
|
concordViewModel = concordViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -788,12 +798,14 @@ fun BottomMenuList(
|
|||||||
onDismiss: (suspend () -> Unit) -> Unit,
|
onDismiss: (suspend () -> Unit) -> Unit,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
|
concordViewModel: ConcordViewModel,
|
||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
|
"Communities (beta)" to { navigator.navigate(Screen.Communities) },
|
||||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Settings" to { navigator.navigate(Screen.Settings) }
|
"Settings" to { navigator.navigate(Screen.Settings) }
|
||||||
)
|
)
|
||||||
@@ -824,6 +836,8 @@ fun BottomMenuList(
|
|||||||
accountViewModel.logout(onLogout = {
|
accountViewModel.logout(onLogout = {
|
||||||
accountViewModel.resetInternalState()
|
accountViewModel.resetInternalState()
|
||||||
chatViewModel.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,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onUpload: () -> Unit,
|
onUpload: (() -> Unit)? = null,
|
||||||
onMicClick: () -> Unit
|
onMicClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
@@ -60,12 +60,16 @@ fun ChatInput(
|
|||||||
capitalization = KeyboardCapitalization.Sentences,
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
imeAction = ImeAction.Default
|
imeAction = ImeAction.Default
|
||||||
),
|
),
|
||||||
leadingIcon = {
|
leadingIcon = if (onUpload == null) {
|
||||||
IconButton(onClick = onUpload) {
|
null
|
||||||
Icon(
|
} else {
|
||||||
painter = painterResource(Res.drawable.ic_add_circle),
|
{
|
||||||
contentDescription = "Upload",
|
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()) },
|
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||||
label = "send_mic_transition"
|
label = "send_mic_transition"
|
||||||
) { isNotEmpty ->
|
) { 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(
|
IconButton(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
|
enabled = isNotEmpty,
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
@@ -89,16 +105,6 @@ fun ChatInput(
|
|||||||
contentDescription = "Send"
|
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()) {
|
if (model.reactions.isNotEmpty()) {
|
||||||
MessageReactions(
|
MessageReactions(
|
||||||
reactions = model.reactions,
|
emojis = model.reactions.map { it.emoji },
|
||||||
|
total = model.reactions.sumOf { it.authors.size },
|
||||||
modifier = Modifier.offset(y = 12.dp)
|
modifier = Modifier.offset(y = 12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -247,13 +248,14 @@ fun ChatMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The reaction chips under a bubble, shared by DMs and Channels. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageReactions(
|
fun MessageReactions(
|
||||||
reactions: List<ReactionGroup>,
|
emojis: List<String>,
|
||||||
|
total: Int,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val totalCount = reactions.sumOf { it.authors.size }
|
val displayEmojis = emojis.take(3)
|
||||||
val displayEmojis = reactions.take(3).map { it.emoji }
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -274,7 +276,7 @@ private fun MessageReactions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (totalCount > 2) {
|
if (total > 2) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
@@ -282,7 +284,7 @@ private fun MessageReactions(
|
|||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = totalCount.toString(),
|
text = total.toString(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -650,7 +650,7 @@ private fun ReplyPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReactionToolbar(
|
fun ReactionToolbar(
|
||||||
onReaction: (String) -> Unit
|
onReaction: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
|
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
|
||||||
|
}
|
||||||
+185
@@ -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)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+254
@@ -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) + "..."
|
||||||
+171
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+237
@@ -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 rust.nostr.sdk.UnsignedEvent
|
||||||
import kotlin.time.Instant
|
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 {
|
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? {
|
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
|
||||||
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
|
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
|
||||||
|
|
||||||
@@ -45,7 +16,6 @@ object ConcordControl {
|
|||||||
if (eidHex.hex32() == null) return null
|
if (eidHex.hex32() == null) return null
|
||||||
|
|
||||||
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: 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
|
if (version == 0uL) return null
|
||||||
|
|
||||||
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
|
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 {
|
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
|
||||||
var meta: CommunityMeta? = null
|
var meta: CommunityMeta? = null
|
||||||
val channels = mutableMapOf<String, ChannelMeta>()
|
val channels = mutableMapOf<String, ChannelMeta>()
|
||||||
@@ -90,16 +54,6 @@ object ConcordControl {
|
|||||||
return ControlFold(meta = meta, channels = channels)
|
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? {
|
private fun winner(group: List<ControlEdition>): ControlEdition? {
|
||||||
val byVersion = group
|
val byVersion = group
|
||||||
.groupBy { it.version }
|
.groupBy { it.version }
|
||||||
|
|||||||
@@ -8,29 +8,6 @@ import rust.nostr.sdk.Keys
|
|||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.SecretKey
|
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(
|
fun hkdfSha256(
|
||||||
ikm: ByteArray,
|
ikm: ByteArray,
|
||||||
info: ByteArray,
|
info: ByteArray,
|
||||||
@@ -39,18 +16,13 @@ fun hkdfSha256(
|
|||||||
): ByteArray {
|
): ByteArray {
|
||||||
require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" }
|
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 saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
|
||||||
val prk = ikm.toByteString().hmacSha256(saltKey)
|
val prk = ikm.toByteString().hmacSha256(saltKey)
|
||||||
|
|
||||||
// Expand: T(n) = HMAC-SHA256(PRK, T(n-1) | info | n), counter being one octet.
|
|
||||||
val out = Buffer()
|
val out = Buffer()
|
||||||
var t = ByteString.EMPTY
|
var t = ByteString.EMPTY
|
||||||
var counter = 1
|
var counter = 1
|
||||||
|
|
||||||
while (out.size < length) {
|
while (out.size < length) {
|
||||||
t = Buffer()
|
t = Buffer()
|
||||||
.write(t)
|
.write(t)
|
||||||
@@ -61,19 +33,10 @@ fun hkdfSha256(
|
|||||||
out.write(t)
|
out.write(t)
|
||||||
counter++
|
counter++
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.readByteArray(length.toLong())
|
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 {
|
fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
|
||||||
require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" }
|
require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" }
|
||||||
return Buffer().apply {
|
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`. */
|
/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */
|
||||||
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
|
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(
|
fun groupSeed(
|
||||||
label: String,
|
label: String,
|
||||||
secret: ByteArray,
|
secret: ByteArray,
|
||||||
@@ -112,62 +58,47 @@ fun groupSeed(
|
|||||||
isValid: (ByteArray) -> Boolean = ::isValidScalar,
|
isValid: (ByteArray) -> Boolean = ::isValidScalar,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val base = hkdfInfo(label, id, epoch)
|
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) {
|
while (counter <= 255) {
|
||||||
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
|
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
|
||||||
val seed = hkdfSha256(secret, info)
|
val seed = hkdfSha256(secret, info)
|
||||||
if (isValid(seed)) return seed
|
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")
|
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 {
|
fun isValidScalar(seed: ByteArray): Boolean {
|
||||||
if (seed.size != 32) return false
|
if (seed.size != 32) return false
|
||||||
var anyNonZero = false
|
var anyNonZero = false
|
||||||
|
|
||||||
for (byte in seed) {
|
for (byte in seed) {
|
||||||
if (byte != 0.toByte()) {
|
if (byte != 0.toByte()) {
|
||||||
anyNonZero = true
|
anyNonZero = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!anyNonZero) return false
|
if (!anyNonZero) return false
|
||||||
for (i in 0 until 32) {
|
for (i in 0 until 32) {
|
||||||
val candidate = seed[i].toInt() and 0xff
|
val candidate = seed[i].toInt() and 0xff
|
||||||
val order = SECP256K1_ORDER[i].toInt() and 0xff
|
val order = SECP256K1_ORDER[i].toInt() and 0xff
|
||||||
if (candidate != order) return candidate < order
|
if (candidate != order) return candidate < order
|
||||||
}
|
}
|
||||||
return false // exactly n, also out of range
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private val SECP256K1_ORDER =
|
private val SECP256K1_ORDER =
|
||||||
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
|
"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 {
|
fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
|
||||||
val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
|
val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
|
||||||
return GroupKey(secretKey, Keys(secretKey).publicKey())
|
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 {
|
fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
|
||||||
require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" }
|
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}" }
|
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()
|
}.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 {
|
fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
|
||||||
require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
|
require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
|
||||||
return Buffer().apply {
|
return Buffer().apply {
|
||||||
@@ -195,22 +118,6 @@ fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
|
|||||||
}.readByteString().sha256().toByteArray()
|
}.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 {
|
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(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" }
|
||||||
require(prev == null || prev.size == 32) { "prev must be 32 bytes" }
|
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.RelayUrl
|
||||||
import rust.nostr.sdk.nip44Decrypt
|
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. */
|
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
|
||||||
internal object ConcordRelayDictionary {
|
internal object ConcordRelayDictionary {
|
||||||
val STOCK = listOf(
|
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. */
|
/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
|
||||||
private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
|
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? {
|
fun parseInviteLink(text: String): ParsedInvite? {
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
val hash = trimmed.indexOf('#')
|
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? {
|
fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||||
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
|
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
|
||||||
// Two header bytes plus the token at minimum.
|
// Two header bytes plus the token at minimum.
|
||||||
@@ -112,8 +78,7 @@ fun decodeInviteFragment(fragment: String): InviteFragment? {
|
|||||||
if (reader.byte() != FRAGMENT_VERSION) return null
|
if (reader.byte() != FRAGMENT_VERSION) return null
|
||||||
|
|
||||||
val flags = reader.byte() ?: 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) {
|
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
|
||||||
ConcordRelayDictionary.STOCK
|
ConcordRelayDictionary.STOCK
|
||||||
} else {
|
} 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? =
|
fun inviteBundleKey(tokenHex: String): GroupKey? =
|
||||||
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
|
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? {
|
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
|
||||||
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
|
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
|
||||||
?: return null
|
?: return null
|
||||||
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
|
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()
|
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> {
|
fun CommunityInvite.problems(): List<String> {
|
||||||
val problems = mutableListOf<String>()
|
val problems = mutableListOf<String>()
|
||||||
|
|
||||||
val ownerBytes = owner.hex32()
|
val ownerBytes = owner.hex32()
|
||||||
val saltBytes = ownerSalt.hex32()
|
val saltBytes = ownerSalt.hex32()
|
||||||
val rootBytes = communityRoot.hex32()
|
val rootBytes = communityRoot.hex32()
|
||||||
|
|
||||||
if (ownerBytes == null) problems += "The invite's owner key is malformed"
|
if (ownerBytes == null) problems += "The invite's owner key is malformed"
|
||||||
if (saltBytes == null) problems += "The invite's owner salt is malformed"
|
if (saltBytes == null) problems += "The invite's owner salt is malformed"
|
||||||
if (rootBytes == null) problems += "The invite's community key is malformed"
|
if (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"
|
problems += "The invite does not prove its community id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (communityId.hex32() == null) problems += "The invite's community id is malformed"
|
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 (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
|
||||||
|
|
||||||
if (channels.size > MAX_INVITE_CHANNELS) {
|
if (channels.size > MAX_INVITE_CHANNELS) {
|
||||||
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
|
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
|
||||||
}
|
}
|
||||||
|
|
||||||
channels.forEachIndexed { index, channel ->
|
channels.forEachIndexed { index, channel ->
|
||||||
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
|
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
|
||||||
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
|
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
|
||||||
@@ -200,20 +150,13 @@ fun CommunityInvite.problems(): List<String> {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CORD-05 §1: an expired bundle still previews, but joining refuses. */
|
|
||||||
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
|
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> {
|
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
|
||||||
val source = relays.ifEmpty { fallback }
|
val source = relays.ifEmpty { fallback }
|
||||||
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
|
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(
|
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
||||||
communityId = communityId.lowercase(),
|
communityId = communityId.lowercase(),
|
||||||
owner = owner.lowercase(),
|
owner = owner.lowercase(),
|
||||||
@@ -230,14 +173,11 @@ fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
|||||||
key = channel.key.lowercase(),
|
key = channel.key.lowercase(),
|
||||||
epoch = channel.epoch,
|
epoch = channel.epoch,
|
||||||
name = channel.name,
|
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),
|
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) {
|
private class FragmentReader(private val bytes: ByteArray) {
|
||||||
var index = 0
|
var index = 0
|
||||||
private set
|
private set
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
package su.reya.coop.concord
|
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 {
|
object ConcordKind {
|
||||||
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
|
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
|
||||||
const val WRAP = 1059
|
const val WRAP = 1059
|
||||||
@@ -86,6 +70,12 @@ object ConcordKind {
|
|||||||
/** Community List — one addressable event per fragment, NIP-44 to self. */
|
/** Community List — one addressable event per fragment, NIP-44 to self. */
|
||||||
const val COMMUNITY_LIST = 33302
|
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. */
|
/** Invite List — replaceable, NIP-44 to self. */
|
||||||
const val INVITE_LIST = 13303
|
const val INVITE_LIST = 13303
|
||||||
}
|
}
|
||||||
@@ -129,10 +119,6 @@ object ConcordVsk {
|
|||||||
const val PIN_LIST = 11
|
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 {
|
object ConcordPermission {
|
||||||
const val MANAGE_ROLES = 1 shl 0
|
const val MANAGE_ROLES = 1 shl 0
|
||||||
const val MANAGE_CHANNELS = 1 shl 1
|
const val MANAGE_CHANNELS = 1 shl 1
|
||||||
@@ -148,12 +134,8 @@ object ConcordPermission {
|
|||||||
const val VIEW_AUDIT_LOG = 1 shl 8
|
const val VIEW_AUDIT_LOG = 1 shl 8
|
||||||
const val MENTION_EVERYONE = 1 shl 9
|
const val MENTION_EVERYONE = 1 shl 9
|
||||||
|
|
||||||
// 1 shl 10 is reserved.
|
|
||||||
|
|
||||||
const val PIN_MESSAGES = 1 shl 11
|
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
|
* 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.
|
* `control_root` and can therefore write to the Control plane.
|
||||||
@@ -162,10 +144,6 @@ object ConcordPermission {
|
|||||||
BAN or CREATE_INVITE or PIN_MESSAGES
|
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 {
|
object ConcordLabel {
|
||||||
/** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
|
/** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
|
||||||
const val CHANNEL = "concord/channel"
|
const val CHANNEL = "concord/channel"
|
||||||
@@ -250,6 +228,9 @@ object ConcordTag {
|
|||||||
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
|
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
|
||||||
const val QUOTE = "q"
|
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. */
|
/** `["vsk", "<n>"]` — Control edition entity type. */
|
||||||
const val VSK = "vsk"
|
const val VSK = "vsk"
|
||||||
|
|
||||||
@@ -277,6 +258,6 @@ object ConcordTag {
|
|||||||
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
|
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
|
||||||
const val INVITE = "invite"
|
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"
|
const val K = "k"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import rust.nostr.sdk.AckPolicy
|
import rust.nostr.sdk.AckPolicy
|
||||||
import rust.nostr.sdk.Event
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.Filter
|
import rust.nostr.sdk.Filter
|
||||||
import rust.nostr.sdk.Kind
|
import rust.nostr.sdk.Kind
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
@@ -22,33 +22,7 @@ import kotlin.concurrent.Volatile
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration.Companion.seconds
|
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) {
|
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
|
@Volatile
|
||||||
private var planes: Map<String, Plane> = emptyMap()
|
private var planes: Map<String, Plane> = emptyMap()
|
||||||
|
|
||||||
@@ -56,6 +30,21 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var folds: Map<String, ControlFold> = emptyMap()
|
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())
|
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
|
||||||
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
|
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
|
||||||
|
|
||||||
@@ -66,6 +55,13 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
|
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
|
||||||
val directInvites: StateFlow<List<CommunityInvite>> = _directInvites.asStateFlow()
|
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
|
private var store: ConcordStore? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,10 +75,6 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
store = ConcordStore(storage, nostr)
|
store = ConcordStore(storage, nostr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Routing
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** True when a wrap's author is one of our planes' stream keys. */
|
/** True when a wrap's author is one of our planes' stream keys. */
|
||||||
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
|
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
|
||||||
|
|
||||||
@@ -99,6 +91,12 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
val store = store ?: return
|
val store = store ?: return
|
||||||
|
|
||||||
store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
|
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
|
// 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.
|
// 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 {
|
fun onInboxRumor(rumor: UnsignedEvent): Boolean {
|
||||||
if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
|
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) {
|
if (invite == null) {
|
||||||
println("Concord: a direct invite arrived but its bundle could not be read")
|
println("Concord: a direct invite arrived but its bundle could not be read")
|
||||||
return true
|
return true
|
||||||
@@ -128,16 +127,17 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_directInvites.update { current ->
|
_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
|
else current + invite
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Lifecycle
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
|
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
|
||||||
suspend fun restore() {
|
suspend fun restore() {
|
||||||
val store = store ?: return
|
val store = store ?: return
|
||||||
@@ -154,10 +154,37 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
refreshPlanes()
|
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() {
|
suspend fun sync() {
|
||||||
|
adoptCommunityList()
|
||||||
|
|
||||||
for (membership in _memberships.value) {
|
for (membership in _memberships.value) {
|
||||||
try {
|
try {
|
||||||
subscribeCommunity(membership)
|
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
|
* 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).
|
* (CORD-05 §1: a bundle is passive until the user accepts).
|
||||||
*/
|
*/
|
||||||
suspend fun previewInvite(link: String): InvitePreview {
|
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")
|
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
|
val parsed = parseInviteLink(link)
|
||||||
// the Community's real relay set (CORD-05 §3).
|
?: 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)
|
val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays)
|
||||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
|
if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
|
||||||
|
|
||||||
relays.forEach { relay ->
|
relays.forEach { relay ->
|
||||||
@@ -202,7 +320,10 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
.identifier(parsed.identifier)
|
.identifier(parsed.identifier)
|
||||||
|
|
||||||
val bundle = client
|
val bundle = client
|
||||||
.fetchEvents(ReqTarget.manual(relays.associateWith { listOf(filter) }), timeout = 8.seconds)
|
.fetchEvents(
|
||||||
|
ReqTarget.manual(relays.associateWith { listOf(filter) }),
|
||||||
|
timeout = 8.seconds
|
||||||
|
)
|
||||||
.toVec()
|
.toVec()
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?: throw IllegalStateException("No invite bundle was found at that link")
|
?: 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. */
|
/** 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. */
|
/** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */
|
||||||
suspend fun join(preview: InvitePreview): CommunityState {
|
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 store = store ?: throw IllegalStateException("Concord storage is not ready")
|
||||||
val membership = preview.invite.toMembership(preview.relays)
|
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)
|
store.saveMemberships(updated)
|
||||||
_memberships.value = updated
|
_memberships.value = updated
|
||||||
|
|
||||||
refreshPlanes()
|
refreshPlanes()
|
||||||
subscribeCommunity(membership)
|
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)
|
if (!preview.alreadyJoined) publishJoin(membership, preview.invite)
|
||||||
|
|
||||||
_directInvites.update { invites ->
|
_directInvites.update { invites ->
|
||||||
invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
|
invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
|
return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
|
||||||
?: CommunityState(membership, null, emptyList())
|
?: CommunityState(membership, null, emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
/** A Channel's messages, oldest first, with reactions and edits folded on top. */
|
||||||
// Reading
|
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
|
* Sends a message to a Channel (CORD-03 §3), and returns it as a reader will see it.
|
||||||
* keeps it current.
|
*
|
||||||
|
* 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> =
|
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage =
|
||||||
store?.cachedRumors(channelIdHex).orEmpty()
|
publish(channelPlane(channelIdHex), ConcordKind.MESSAGE, content)
|
||||||
.mapNotNull { it.toConcordMessage() }
|
.toConcordMessage()
|
||||||
.sortedBy { it.timestampMs }
|
?: 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.
|
* 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 }
|
val granted = membership.channels.associateBy { it.id }
|
||||||
|
|
||||||
if (communityId != null && root != null) {
|
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 ->
|
membership.controlPk?.let { controlPkHex ->
|
||||||
if (controlPkHex.hex32() != null) {
|
if (controlPkHex.hex32() != null) {
|
||||||
val key = controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
|
val key =
|
||||||
|
controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
|
||||||
next[key.streamPublicKeyHex] = Plane(
|
next[key.streamPublicKeyHex] = Plane(
|
||||||
communityIdHex = membership.communityId,
|
communityIdHex = membership.communityId,
|
||||||
role = PlaneRole.Control,
|
role = PlaneRole.Control,
|
||||||
@@ -314,8 +560,6 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
val stored = granted[channelIdHex]
|
val stored = granted[channelIdHex]
|
||||||
val channelId = channelIdHex.hex32()
|
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()
|
val secret = stored?.key?.hex32()
|
||||||
?: if (meta?.isPrivate != true) root else null
|
?: if (meta?.isPrivate != true) root else null
|
||||||
val epoch = stored?.epoch ?: membership.rootEpoch
|
val epoch = stored?.epoch ?: membership.rootEpoch
|
||||||
@@ -337,6 +581,9 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
private = meta?.isPrivate ?: stored?.private ?: false,
|
private = meta?.isPrivate ?: stored?.private ?: false,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
hasKey = plane != null,
|
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. */
|
/** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */
|
||||||
private suspend fun refreshControl(communityIdHex: String) {
|
private suspend fun refreshControl(communityIdHex: String) {
|
||||||
val store = store ?: return
|
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))
|
folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex))
|
||||||
|
|
||||||
if (communityIdHex in refreshPlanes()) {
|
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)
|
val id = subscriptionId(membership.communityId)
|
||||||
client.unsubscribe(id)
|
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
|
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 ->
|
relays.forEach { relay ->
|
||||||
client.addRelay(relay)
|
client.addRelay(relay)
|
||||||
client.connectRelay(relay)
|
client.connectRelay(relay)
|
||||||
@@ -396,7 +645,10 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
.filter { it.communityIdHex == membership.communityId }
|
.filter { it.communityIdHex == membership.communityId }
|
||||||
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
|
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
|
||||||
.distinct()
|
.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
|
if (filters.isEmpty()) return
|
||||||
|
|
||||||
client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id)
|
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 client = nostr.client ?: return
|
||||||
val communityId = membership.communityId.hex32() ?: return
|
val communityId = membership.communityId.hex32() ?: return
|
||||||
val root = membership.communityRoot.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)
|
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
|
||||||
val extraTags = invite?.creatorNpub?.let { creator ->
|
?.let { creator ->
|
||||||
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
|
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
|
|
||||||
val rumor = plane.rumor(
|
val rumor = plane.rumor(
|
||||||
author = author,
|
author = author,
|
||||||
@@ -425,11 +678,18 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
)
|
)
|
||||||
val wrap = plane.wrap(rumor, nostr.signer)
|
val wrap = plane.wrap(rumor, nostr.signer)
|
||||||
|
|
||||||
val targets = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
val targets =
|
||||||
client.sendEvent(event = wrap, target = SendEventTarget.to(targets), ackPolicy = AckPolicy.none())
|
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(
|
private fun preview(
|
||||||
invite: CommunityInvite,
|
invite: CommunityInvite,
|
||||||
@@ -437,14 +697,21 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
fallbackRelays: List<String>,
|
fallbackRelays: List<String>,
|
||||||
): InvitePreview {
|
): InvitePreview {
|
||||||
val relays = invite.relaySet(fallbackRelays)
|
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(
|
return InvitePreview(
|
||||||
link = link,
|
link = link,
|
||||||
invite = invite,
|
invite = invite,
|
||||||
relays = relays,
|
relays = relays,
|
||||||
problems = problems,
|
problems = problems,
|
||||||
expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
|
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 rust.nostr.sdk.UnsignedEvent
|
||||||
import kotlin.time.Instant
|
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 {
|
internal val concordJson = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = 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
|
@Serializable
|
||||||
data class Membership(
|
data class Membership(
|
||||||
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
|
/** 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(),
|
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
|
@Serializable
|
||||||
data class StoredChannelKey(
|
data class StoredChannelKey(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -72,18 +44,6 @@ data class StoredChannelKey(
|
|||||||
val private: Boolean = false,
|
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
|
@Serializable
|
||||||
data class CommunityInvite(
|
data class CommunityInvite(
|
||||||
@SerialName("community_id") val communityId: String = "",
|
@SerialName("community_id") val communityId: String = "",
|
||||||
@@ -111,10 +71,6 @@ data class InviteChannel(
|
|||||||
val name: String? = null,
|
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
|
@Serializable
|
||||||
data class ConcordIcon(
|
data class ConcordIcon(
|
||||||
val url: String? = null,
|
val url: String? = null,
|
||||||
@@ -123,10 +79,6 @@ data class ConcordIcon(
|
|||||||
val hash: String? = null,
|
val hash: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
// Control fold
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
|
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CommunityMeta(
|
data class CommunityMeta(
|
||||||
@@ -149,13 +101,6 @@ data class ChannelMeta(
|
|||||||
val deleted: Boolean = false,
|
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(
|
data class ControlEdition(
|
||||||
val vsk: Int,
|
val vsk: Int,
|
||||||
val eidHex: String,
|
val eidHex: String,
|
||||||
@@ -173,10 +118,6 @@ data class ControlFold(
|
|||||||
val channels: Map<String, ChannelMeta>,
|
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. */
|
/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
|
||||||
data class ConcordChannel(
|
data class ConcordChannel(
|
||||||
val idHex: String,
|
val idHex: String,
|
||||||
@@ -185,6 +126,11 @@ data class ConcordChannel(
|
|||||||
val epoch: ULong,
|
val epoch: ULong,
|
||||||
/** False for a Private Channel we were never granted — listable, but not readable. */
|
/** False for a Private Channel we were never granted — listable, but not readable. */
|
||||||
val hasKey: Boolean,
|
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. */
|
/** A Community with its Control fold applied. */
|
||||||
@@ -195,6 +141,12 @@ data class CommunityState(
|
|||||||
val channels: List<ConcordChannel>,
|
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. */
|
/** A Chat-plane message, already unwrapped and signature-checked. */
|
||||||
data class ConcordMessage(
|
data class ConcordMessage(
|
||||||
val idHex: String,
|
val idHex: String,
|
||||||
@@ -203,6 +155,9 @@ data class ConcordMessage(
|
|||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
|
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
|
||||||
val timestampMs: Long,
|
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). */
|
/** 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
|
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 }
|
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 {
|
internal fun UnsignedEvent.concordTimestampMs(): Long {
|
||||||
val ms = tags().toVec()
|
val ms = tags().toVec()
|
||||||
.firstOrNull { it.kind() == ConcordTag.MS }
|
.firstOrNull { it.kind() == ConcordTag.MS }
|
||||||
@@ -241,7 +186,6 @@ internal fun UnsignedEvent.concordTimestampMs(): Long {
|
|||||||
return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
|
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? {
|
internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
|
||||||
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
|
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
|
||||||
return ConcordMessage(
|
return ConcordMessage(
|
||||||
@@ -252,3 +196,51 @@ internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
|
|||||||
timestampMs = concordTimestampMs(),
|
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 rust.nostr.sdk.nip44Encrypt
|
||||||
import kotlin.time.Instant
|
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) {
|
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),
|
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);
|
Plaintext(ConcordKind.PLAINTEXT_SEAL);
|
||||||
|
|
||||||
val kind: UShort get() = kindValue.toUShort()
|
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)
|
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(
|
class PlaneKey(
|
||||||
val read: GroupKey,
|
val read: GroupKey,
|
||||||
val stream: GroupKey?,
|
val stream: GroupKey?,
|
||||||
/** The x-only hex of the key every wrap on this plane must be signed by. */
|
|
||||||
val streamPublicKeyHex: String,
|
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 form: SealForm,
|
||||||
val chat: ChatBinding?,
|
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 {
|
fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
|
||||||
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
|
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
|
||||||
return PlaneKey(
|
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 {
|
fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
|
||||||
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
|
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
|
||||||
return PlaneKey(
|
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(
|
fun controlPlaneKey(
|
||||||
communityRoot: ByteArray,
|
communityRoot: ByteArray,
|
||||||
communityId: 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(
|
fun PlaneKey.rumor(
|
||||||
author: PublicKey,
|
author: PublicKey,
|
||||||
kind: UShort,
|
kind: UShort,
|
||||||
@@ -172,47 +92,28 @@ fun PlaneKey.rumor(
|
|||||||
.ensureId()
|
.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 {
|
suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event {
|
||||||
val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
|
val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
|
||||||
val createdAt = rumor.createdAt()
|
val createdAt = rumor.createdAt()
|
||||||
|
|
||||||
val rumorJson = rumor.asJson()
|
val rumorJson = rumor.asJson()
|
||||||
|
val content = if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson
|
||||||
|
|
||||||
val seal = try {
|
val seal = try {
|
||||||
EventBuilder(
|
EventBuilder(Kind(form.kind), content).customCreatedAt(createdAt).finalizeAsync(signer)
|
||||||
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)
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
|
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()) {
|
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 {
|
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()) }
|
val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
|
||||||
Keys(stream.secretKey).use { keys ->
|
Keys(stream.secretKey).use { keys ->
|
||||||
EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
|
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? {
|
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
||||||
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
|
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.author().toHex() != streamPublicKeyHex) return null
|
||||||
if (!event.verify()) return null
|
if (!event.verify()) return null
|
||||||
|
|
||||||
@@ -248,8 +138,6 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
|||||||
}.getOrNull() ?: return null
|
}.getOrNull() ?: return null
|
||||||
if (!seal.verify()) 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 sealKind = seal.kind().asU16()
|
||||||
val rumorJson = when {
|
val rumorJson = when {
|
||||||
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
|
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
|
||||||
@@ -260,19 +148,14 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
|||||||
else -> return null
|
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.
|
// NIP-59's impersonation check: the seal proves who wrote the rumor inside it.
|
||||||
if (rumor.author() != seal.author()) return null
|
if (rumor.author() != seal.author()) return null
|
||||||
if (!bindsToThisPlane(rumor)) return null
|
if (!bindsToThisPlane(rumor)) return null
|
||||||
return rumor
|
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 {
|
private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
|
||||||
val expected = chat ?: return true
|
val expected = chat ?: return true
|
||||||
val tags = rumor.tags().toVec()
|
val tags = rumor.tags().toVec()
|
||||||
@@ -281,20 +164,9 @@ private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
|
|||||||
return channel == expected.channelIdHex && epoch == expected.epoch.toString()
|
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()
|
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
|
||||||
|
|
||||||
/**
|
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
||||||
* 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 nip44Seal(key: GroupKey, plaintext: String): String {
|
private fun nip44Seal(key: GroupKey, plaintext: String): String {
|
||||||
val size = plaintext.encodeToByteArray().size
|
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)
|
return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */
|
private const val MAX_PLAINTEXT_BYTES = 65_535
|
||||||
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package su.reya.coop.concord
|
package su.reya.coop.concord
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
import rust.nostr.sdk.EventBuilder
|
||||||
import rust.nostr.sdk.Filter
|
import rust.nostr.sdk.Filter
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
@@ -13,19 +11,7 @@ import rust.nostr.sdk.UnsignedEvent
|
|||||||
import su.reya.coop.AppStorage
|
import su.reya.coop.AppStorage
|
||||||
import su.reya.coop.nostr.Nostr
|
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) {
|
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||||
|
|
||||||
suspend fun loadMemberships(): List<Membership> {
|
suspend fun loadMemberships(): List<Membership> {
|
||||||
val raw = try {
|
val raw = try {
|
||||||
storage.getSecret(MEMBERSHIPS_KEY)
|
storage.getSecret(MEMBERSHIPS_KEY)
|
||||||
@@ -54,27 +40,28 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun clearMemberships() {
|
||||||
* Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the
|
try {
|
||||||
* community id for Control and Guestbook.
|
storage.clear(MEMBERSHIPS_KEY)
|
||||||
*
|
} catch (e: CancellationException) {
|
||||||
* The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable*
|
throw e
|
||||||
* kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every
|
} catch (e: Exception) {
|
||||||
* message would replace the one before it and history would vanish silently. The wrap id is
|
println("Concord: could not clear memberships: ${e.message}")
|
||||||
* 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 cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
|
suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
|
||||||
try {
|
try {
|
||||||
val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
val event =
|
||||||
.tags(
|
EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
||||||
listOf(
|
.tags(
|
||||||
Tag.identifier(wrapIdHex),
|
listOf(
|
||||||
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
|
Tag.identifier(wrapIdHex),
|
||||||
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
|
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)
|
nostr.client?.database()?.saveEvent(event)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
@@ -99,14 +86,16 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return events
|
return events
|
||||||
.mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() }
|
.mapNotNull {
|
||||||
|
runCatching {
|
||||||
|
UnsignedEvent.fromJson(it.content()).ensureId()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
.sortedBy { it.createdAt().asSecs() }
|
.sortedBy { it.createdAt().asSecs() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MEMBERSHIPS_KEY = "concord_memberships"
|
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_SCOPE_TAG = "r"
|
||||||
const val INDEX_KIND_TAG = "k"
|
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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user