add simple ui
This commit is contained in:
@@ -12,6 +12,24 @@ 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)) |
|
||||||
|
| M4.5 — community creation | ⬜ optional, still unanswered |
|
||||||
|
| M6 — reactions / edits | ⬜ optional |
|
||||||
|
|
||||||
|
**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
|
||||||
@@ -187,21 +205,28 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue
|
|||||||
|
|
||||||
### 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**
|
||||||
|
|
||||||
@@ -209,17 +234,28 @@ Decision: no test files are maintained for this feature. M1–M4 were verified d
|
|||||||
|
|
||||||
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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -655,15 +691,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` comes from genesis. 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`. 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. |
|
||||||
| `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
|
||||||
@@ -790,12 +829,34 @@ Owner mints `community_id`, `community_root`, `control_root`, genesis metadata (
|
|||||||
|
|
||||||
**Why it might wait:** requires `control_root` handling and careful `20014` discipline.
|
**Why it might wait:** requires `control_root` handling and careful `20014` discipline.
|
||||||
|
|
||||||
### M5 — UI polish
|
### M5 — UI — **done**
|
||||||
|
|
||||||
`ConcordRepository` / view models / 4 screens / `BottomMenuList` entry / icon / error snackbars.
|
`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.
|
||||||
|
|
||||||
|
What shipped:
|
||||||
|
|
||||||
|
- **`ConcordRepository`** — flows forwarded from the manager, and one `attempt` funnel so no screen sees an exception. There is no state of its own: `ConcordManager` already pushes the read model, so a copy here would only be something to keep in sync.
|
||||||
|
- **`ConcordViewModel`** (activity-scoped) and **`ChannelScreenViewModel`** (entry-scoped). The Channel one re-reads on `revision` rather than on an event, cancels any in-flight read so a burst of revisions cannot let an older snapshot land last, and only replaces the list when the ids actually changed — so a message in *another* community costs one indexed query and no recomposition.
|
||||||
|
- **5 screens** in `screens/communities/`, mirroring `HomeScreen` / `ContactListScreen` / `ChatScreen` / `NewChatScreen` row for row.
|
||||||
|
- **Reused, not reimplemented:** `ChatInput` and `DateSeparator` from `screens/chat`. `ChatInput`'s `onUpload` / `onMicClick` became nullable — Concord has neither in v1, and two live-looking buttons that do nothing would be worse than their absence.
|
||||||
|
- **Two small backend additions the UI genuinely needed:** `restored`, because an empty membership list means the same thing before and after `restore()`; and `reset` / `clearMemberships`, because membership keys are identity-scoped and must not survive a logout (risk 17).
|
||||||
|
- **Logout now drops Concord state.** `HomeScreen`'s logout path calls `resetInternalState()` alongside the account and chat ones.
|
||||||
|
- **QR scan routes invite links.** `HomeScreen` checks `parseInviteLink` before `PublicKey.parse`, so scanning an invite lands on the Join screen; `JoinCommunityScreen` also reads `LocalScanResult` when reached from the Communities FAB.
|
||||||
|
|
||||||
|
**Verified:** see §14. The UI itself was verified by **compilation on both targets** — `:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` — and by nothing else. **No screen has been run.** `adb`/emulator is the only way to exercise the layout, the nav routes and the snackbar collector, and that has not been done.
|
||||||
|
|
||||||
|
### M5.5 — not done, and worth knowing
|
||||||
|
|
||||||
|
| Gap | Why it is not there |
|
||||||
|
|---|---|
|
||||||
|
| **No refresh / retry.** `sync()` is only called at startup and on join. A relay that refuses a subscription at startup is not retried until the app restarts. | Adding a button would not help: `sync()` only re-subscribes, it does not backfill. A real retry needs `fetchEvents`-based backfill on the plane addresses, which is a backend change |
|
||||||
|
| **No message backfill.** History is whatever LMDB cached while subscribed. | The same missing `fetchEvents` path |
|
||||||
|
| **No reactions, edits, replies, attachments, threads.** | M6 |
|
||||||
|
| **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 — Cheap wins (optional)
|
### M6 — Cheap wins (optional)
|
||||||
|
|
||||||
Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI. Threads (`kind 1111`) are more work — separate milestone.
|
Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI. Threads (`kind 1111`) are more work — separate milestone.
|
||||||
@@ -830,6 +891,8 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
|||||||
| 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. |
|
| 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). |
|
| 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. |
|
||||||
|
|
||||||
### 13.3 Open decision
|
### 13.3 Open decision
|
||||||
|
|
||||||
@@ -839,7 +902,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
|||||||
|
|
||||||
## 14. Verification plan
|
## 14. Verification plan
|
||||||
|
|
||||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M4,
|
**No test files are kept** (decision, M2). The checks below were written and run during M1–M5,
|
||||||
then discarded; the tree carries no Concord sources under `commonTest` or `iosTest`. 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.
|
||||||
@@ -856,7 +919,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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -884,6 +947,10 @@ there is no regression net, so:
|
|||||||
| a delivered Channel message raises the badge; our own message and a non-message raise nothing | property | iOS ✅ M4 |
|
| 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 |
|
| `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 |
|
| 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 |
|
||||||
|
| 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):
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +169,11 @@ fun HomeScreen(
|
|||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
|
// A Concord invite is a link, not a npub, so it is routed to the Join screen instead of
|
||||||
|
// being force-fed to PublicKey.parse.
|
||||||
|
if (parseInviteLink(result) != null) {
|
||||||
|
navigator.navigate(Screen.JoinCommunity(result))
|
||||||
|
} else {
|
||||||
runCatching { PublicKey.parse(result) }
|
runCatching { PublicKey.parse(result) }
|
||||||
.onSuccess { pubkey ->
|
.onSuccess { pubkey ->
|
||||||
try {
|
try {
|
||||||
@@ -176,6 +184,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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,13 +60,17 @@ fun ChatInput(
|
|||||||
capitalization = KeyboardCapitalization.Sentences,
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
imeAction = ImeAction.Default
|
imeAction = ImeAction.Default
|
||||||
),
|
),
|
||||||
leadingIcon = {
|
leadingIcon = if (onUpload == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{
|
||||||
IconButton(onClick = onUpload) {
|
IconButton(onClick = onUpload) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_add_circle),
|
painter = painterResource(Res.drawable.ic_add_circle),
|
||||||
contentDescription = "Upload",
|
contentDescription = "Upload",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
@@ -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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package su.reya.coop.screens.communities
|
||||||
|
|
||||||
|
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.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.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("") }
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// 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 = {
|
||||||
|
viewModel.sendMessage(text)
|
||||||
|
text = ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) {
|
||||||
|
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)) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = message.content,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 each was
|
|
||||||
* checked against RFC 5869 vectors and independently computed digests before it was written.
|
|
||||||
* Concord ships no test vectors of its own ("Examples are illustrative, not verifiable test
|
|
||||||
* vectors"), and no test file is kept — see PLAN.md §14.
|
|
||||||
*
|
|
||||||
* 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 re-checked by hand; 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 was checked against it.
|
|
||||||
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
|
||||||
@@ -129,10 +113,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 +128,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 +138,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"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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.Filter
|
import rust.nostr.sdk.Filter
|
||||||
@@ -22,33 +21,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()
|
||||||
|
|
||||||
@@ -81,6 +54,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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,10 +74,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)
|
||||||
|
|
||||||
@@ -138,7 +114,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
|
||||||
@@ -149,16 +126,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
|
||||||
@@ -175,6 +153,31 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
refreshPlanes()
|
refreshPlanes()
|
||||||
|
_restored.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops every membership and everything derived from it.
|
||||||
|
*
|
||||||
|
* Membership keys are identity-scoped — holding them *is* being in the Community (CORD-02 §2) —
|
||||||
|
* so signing out has to take them with it, or the next identity on this device holds a seat it
|
||||||
|
* never took. The cached rumors go with `Nostr.prune()`; the subscriptions are dropped here,
|
||||||
|
* since nothing else would.
|
||||||
|
*/
|
||||||
|
suspend fun reset() {
|
||||||
|
_memberships.value.forEach { membership ->
|
||||||
|
nostr.client?.unsubscribe(subscriptionId(membership.communityId))
|
||||||
|
}
|
||||||
|
|
||||||
|
store?.clearMemberships()
|
||||||
|
|
||||||
|
_memberships.value = emptyList()
|
||||||
|
_directInvites.value = emptyList()
|
||||||
|
|
||||||
|
folds = emptyMap()
|
||||||
|
unread = emptyMap()
|
||||||
|
|
||||||
|
refreshPlanes()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Network half of startup: connect every Community's relays and subscribe to its planes. */
|
/** Network half of startup: connect every Community's relays and subscribe to its planes. */
|
||||||
@@ -190,26 +193,25 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Invites
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* would mean. Nothing is joined, nothing is subscribed and no presence is announced here
|
* would mean. Nothing is joined, nothing is subscribed and no presence is announced here
|
||||||
* (CORD-05 §1: a bundle is passive until the user accepts).
|
* (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 ->
|
||||||
@@ -223,7 +225,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")
|
||||||
@@ -239,7 +244,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 {
|
||||||
@@ -249,28 +265,24 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Reading
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
|
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
|
||||||
* keeps it current.
|
* keeps it current.
|
||||||
@@ -290,10 +302,6 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
publishUnread(channelIdHex, 0)
|
publishUnread(channelIdHex, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Writing
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a message to a Channel (CORD-03 §3).
|
* Sends a message to a Channel (CORD-03 §3).
|
||||||
*
|
*
|
||||||
@@ -310,10 +318,13 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
*/
|
*/
|
||||||
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage {
|
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage {
|
||||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
||||||
|
val author =
|
||||||
|
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
val plane = planes.values.firstOrNull {
|
val plane = planes.values.firstOrNull {
|
||||||
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
||||||
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
||||||
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val rumor = plane.key.rumor(
|
val rumor = plane.key.rumor(
|
||||||
author = author,
|
author = author,
|
||||||
@@ -334,8 +345,11 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||||
if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
|
if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
|
||||||
|
|
||||||
client.sendEvent(event = wrap, target = SendEventTarget.to(relays), ackPolicy = AckPolicy.none())
|
client.sendEvent(
|
||||||
.failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") }
|
event = wrap,
|
||||||
|
target = SendEventTarget.to(relays),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
).failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") }
|
||||||
|
|
||||||
return rumor.toConcordMessage()
|
return rumor.toConcordMessage()
|
||||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
||||||
@@ -365,10 +379,6 @@ class ConcordManager(private val nostr: Nostr) {
|
|||||||
publishUnread(channelIdHex, count)
|
publishUnread(channelIdHex, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Planes
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*
|
*
|
||||||
@@ -387,14 +397,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,
|
||||||
@@ -420,8 +426,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
|
||||||
@@ -470,11 +474,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,12 +496,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)
|
||||||
@@ -505,7 +511,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)
|
||||||
@@ -516,12 +525,13 @@ 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()
|
||||||
|
|
||||||
@@ -534,11 +544,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,
|
||||||
@@ -546,14 +563,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,
|
||||||
@@ -225,18 +166,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 }
|
||||||
@@ -246,7 +177,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(
|
||||||
|
|||||||
@@ -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,19 +40,20 @@ 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 =
|
||||||
|
EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
||||||
.tags(
|
.tags(
|
||||||
listOf(
|
listOf(
|
||||||
Tag.identifier(wrapIdHex),
|
Tag.identifier(wrapIdHex),
|
||||||
@@ -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,73 @@
|
|||||||
|
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) }
|
||||||
|
|
||||||
|
/** 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,69 @@
|
|||||||
|
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)
|
||||||
|
if (messages.map { it.idHex } != loaded.map { it.idHex }) {
|
||||||
|
messages.clear()
|
||||||
|
messages.addAll(loaded)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
concordRepository.markChannelRead(channelId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(text: String) {
|
||||||
|
if (text.isBlank()) return
|
||||||
|
viewModelScope.launch { concordRepository.sendMessage(channelId, 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