From 63ef074e8f4db5edc320287198cf2666c5cee978 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 15 Sep 2026 11:07:41 +0700 Subject: [PATCH] add simple ui --- PLAN.md | 121 ++++++-- .../drawable/ic_communities.xml | 18 ++ .../composeResources/drawable/ic_lock.xml | 15 + .../androidMain/kotlin/su/reya/coop/App.kt | 50 +++- .../kotlin/su/reya/coop/MainActivity.kt | 6 + .../kotlin/su/reya/coop/Navigation.kt | 12 + .../kotlin/su/reya/coop/screens/HomeScreen.kt | 36 ++- .../su/reya/coop/screens/chat/ChatInput.kt | 44 +-- .../coop/screens/communities/ChannelScreen.kt | 281 ++++++++++++++++++ .../screens/communities/CommunitiesScreen.kt | 185 ++++++++++++ .../communities/CommunityComponents.kt | 254 ++++++++++++++++ .../screens/communities/CommunityScreen.kt | 171 +++++++++++ .../communities/JoinCommunityScreen.kt | 237 +++++++++++++++ .../kotlin/su/reya/coop/Community.kt | 46 +++ .../su/reya/coop/concord/ConcordControl.kt | 46 --- .../su/reya/coop/concord/ConcordCrypto.kt | 113 +------ .../su/reya/coop/concord/ConcordInvite.kt | 68 +---- .../su/reya/coop/concord/ConcordKind.kt | 28 -- .../su/reya/coop/concord/ConcordManager.kt | 208 +++++++------ .../su/reya/coop/concord/ConcordModels.kt | 70 ----- .../su/reya/coop/concord/ConcordPlane.kt | 149 +--------- .../su/reya/coop/concord/ConcordStore.kt | 59 ++-- .../reya/coop/repository/ConcordRepository.kt | 73 +++++ .../coop/viewmodel/ChannelScreenViewModel.kt | 69 +++++ .../reya/coop/viewmodel/ConcordViewModel.kt | 27 ++ 25 files changed, 1751 insertions(+), 635 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_communities.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_lock.xml create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunitiesScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityComponents.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/JoinCommunityScreen.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Community.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/ConcordRepository.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChannelScreenViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ConcordViewModel.kt diff --git a/PLAN.md b/PLAN.md index c44ea46..51838b4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. 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 @@ -187,21 +205,28 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue ### 5.2 New — `shared/src/commonMain/kotlin/su/reya/coop/` -| File | Contents | -|---|---| -| `Community.kt` | UI-facing models + derived flows, mirroring `Room.kt` / `RoomUiState` | -| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, `MutableStateFlow`, `stateIn(scope, WhileSubscribed(5000), …)` | -| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel` | -| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel` (`mutableStateListOf`) | - -### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/` +**Delivered in M5.** | File | Contents | |---|---| -| `CommunitiesScreen.kt` | Joined communities list + FAB → Join | -| `CommunityScreen.kt` | Channel list for one community | -| `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join | -| `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states | +| `Community.kt` | Display helpers over the read model — `CommunityState.displayName()` / `.unreadTotal()`, `ConcordMessage.timeLabel()` / `.dayLabel()`. Deliberately *not* a `RoomUiState`-style mirror: a Community's name is already in the fold, so there is no async lookup to model | +| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, flows forwarded straight from the manager, and one private `attempt` funnel that hops to `defaultDispatcher` and reports instead of throwing | +| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel`. `previewInvite` / `join` stay **suspend** so the Join screen owns its spinner | +| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel`, but re-reads on the manager's `revision` counter since a plane message is not pushed as an event | + +### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/` + +**Delivered in M5.** All five live in one `communities` package, matching the `screens/chat/` convention. + +| File | Contents | +|---|---| +| `CommunitiesScreen.kt` | Joined communities list + Direct Invite cards + FAB → Join | +| `CommunityScreen.kt` | Channel list for one community, split public / private | +| `ChannelScreen.kt` | Channel history, Discord-style. **Not in the original §5.3 list** — it was implied by the §11 table but omitted from the file plan | +| `JoinCommunityScreen.kt` | Paste link / scan QR → preview card → Join | +| `CommunityComponents.kt` | `CommunityRow`, `ChannelRow`, `DirectInviteCard`, `CommunityEmptyState`, `BetaNotice`, `shortCommunityId` | + +**Deviation:** there is no `CommunityScreenViewModel`. That screen is a pure lookup of one `CommunityState` by id inside the existing `communities` flow, so an entry-scoped view model would have been a file with no state in it. ### 5.4 Tests — **none are kept** @@ -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. -### 5.5 Modified (7 files, all small) +### 5.5 Modified + +Wired in M3 (before the UI existed): | File | Change | |---|---| -| `shared/.../nostr/Nostr.kt` | add `val concord`, `init(dbPath, storage)`, 2 routing branches | +| `shared/.../nostr/Nostr.kt` | `val concord`, `init(dbPath, storage)`, 2 routing branches | | `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` | -| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, add to `App(...)` params | -| `composeApp/.../App.kt` | factory branch, `viewModel(...)`, 3 × `entry<…>`, snackbar collector | -| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` | -| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList` | -| `composeApp/src/androidMain/composeResources/drawable/ic_communities.xml` | icon | + +Wired in M5: + +| File | Change | +|---|---| +| `shared/.../concord/ConcordManager.kt` | `restored` StateFlow, `dismissDirectInvite`, `reset` | +| `shared/.../concord/ConcordStore.kt` | `clearMemberships` | +| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(communityId)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity(link)` | +| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, passed into `App(...)` | +| `composeApp/.../App.kt` | factory branch, activity-scoped `ConcordViewModel`, 4 × `entry<…>`, snackbar collector | +| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList`; QR results routed by `parseInviteLink`; `concordViewModel.resetInternalState()` on logout | +| `composeApp/.../screens/chat/ChatInput.kt` | `onUpload` / `onMicClick` became nullable, so a screen with neither shows a disabled send button instead of two dead ones | +| `composeApp/.../composeResources/drawable/ic_communities.xml` | icon (empty state) | +| `composeApp/.../composeResources/drawable/ic_lock.xml` | icon (private channel, beta notice) | --- @@ -655,15 +691,18 @@ If a deep link is wanted later, add `coop://invite?url=` and let Coo ## 11. UI -Mirrors the DM screens structurally so it feels native to Coop. +Mirrors the DM screens structurally so it feels native to Coop. **Delivered in M5** — see +[M5](#m5--ui--done) for what actually shipped and what did not. | Screen | Mirrors | Notes | |---|---|---| -| `CommunitiesScreen` | `HomeScreen` list + FAB | Community avatar (`Avatar`), name, channel count. Empty state per `ContactListScreen` convention. | -| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock icon on private. `#general` comes from genesis. | -| `ChannelScreen` | `ChatScreen` | Reuse `DateSeparator`, `ChatInput`, `Avatar`. Discord-style (author shown per message) rather than Coop's DM style. | +| `CommunitiesScreen` | `HomeScreen` list + FAB | Community name and channel count, plus Direct Invite cards above the list. **No avatar** for a Community: its icon is an encrypted blob v1 never fetches, so the row shows the placeholder rather than a picture it does not have. Empty state per `ContactListScreen` convention. | +| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock on private, `No key` on one we cannot read. `#general` comes from genesis. Carries the beta notice from §11.2. | +| `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. | +**Deviation:** `ChannelScreen` is not in the §5.3 file list even though this table always implied it — the file plan simply missed it. + ### 11.1 Wiring, following existing conventions ```kotlin @@ -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. -### 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. +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) 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. | | 15 | **Unread badges live in memory only**, so a restart clears every one of them (§9.4). | This is the DM path's behaviour too (`Room.unreadCount` is likewise in-memory). Persisting it means a read-marker store, a new key and a new write path, all for a badge — worth doing only when a user asks for it. | | 16 | **The write path has no offline check.** `sendChannelMessage` needs a live `client`, so nothing kept in the repo exercises a publish, and the `MemoryStorage` fake used by the M4 check never touches Android Keystore. | Acceptance is interop smoke test step 4. Read and write share `PlaneKey.wrap`, and reading is verified independently (M2/M3), so a send-only failure localises to the publish: the relay set, or a relay dropping the wrap (risk 4). | +| 17 | **Logout had to be taught about Concord.** `AccountRepository.logout` cleared the signer and wiped LMDB but nothing else, so the membership blob — which *is* the keys to every Community (CORD-02 §2) — would have survived into the next identity on the same device, silently granting it seats it never took. | `ConcordManager.reset()` clears the storage key, drops the subscriptions and re-indexes to nothing; `HomeScreen`'s logout path calls it. **Interactive confirmation is out of reach** — `logout` is not suspend and the reset is launched from it — so what is verified is that the call path exists and compiles, not that a logout actually erased the blob on a device. | +| 18 | **`restored` must not flip back on reset.** If `reset()` set it false, the Communities screen would spin forever after a logout, because nothing re-runs `restore()` until the notification pump is rebuilt and the pump has no restart path. | `restored` means "memberships have been loaded", which stays true after they are dropped. Stated in a comment at the assignment. | ### 13.3 Open decision @@ -839,7 +902,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI ## 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 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. @@ -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 `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 | |---|---|---| @@ -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 | | `markChannelRead` clears and republishes; a re-index carries the badge across | property | iOS ✅ M4 | | a sent rumor is exactly `channel`/`epoch`/`ms` at `kind 9`, refused by the Guestbook and another Channel | unit | iOS ✅ M4 | +| `:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` after the M5 wiring, with the new classes confirmed present in `build/` | compile | Android + iOS ✅ M5 | +| 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): diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_communities.xml b/composeApp/src/androidMain/composeResources/drawable/ic_communities.xml new file mode 100644 index 0000000..5235323 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_communities.xml @@ -0,0 +1,18 @@ + + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_lock.xml b/composeApp/src/androidMain/composeResources/drawable/ic_lock.xml new file mode 100644 index 0000000..39fced1 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_lock.xml @@ -0,0 +1,15 @@ + + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 786d339..9d2f611 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -39,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.launch import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.ChatRepository +import su.reya.coop.repository.ConcordRepository import su.reya.coop.repository.SettingsRepository import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen @@ -54,9 +55,15 @@ import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.SettingsScreen import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.chat.ChatScreen +import su.reya.coop.screens.communities.ChannelScreen +import su.reya.coop.screens.communities.CommunitiesScreen +import su.reya.coop.screens.communities.CommunityScreen +import su.reya.coop.screens.communities.JoinCommunityScreen import su.reya.coop.viewmodel.AccountViewModel +import su.reya.coop.viewmodel.ChannelScreenViewModel import su.reya.coop.viewmodel.ChatScreenViewModel import su.reya.coop.viewmodel.ChatViewModel +import su.reya.coop.viewmodel.ConcordViewModel import su.reya.coop.viewmodel.ProfileCache import su.reya.coop.viewmodel.SettingsViewModel @@ -90,6 +97,7 @@ fun App( profileCache: ProfileCache, accountRepository: AccountRepository, chatRepository: ChatRepository, + concordRepository: ConcordRepository, settingsRepository: SettingsRepository, connectivityMonitor: ConnectivityMonitor, ) { @@ -109,6 +117,10 @@ fun App( settingsRepository ) + modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel( + concordRepository + ) + else -> throw IllegalArgumentException("Unknown ViewModel class") } @Suppress("UNCHECKED_CAST") @@ -120,6 +132,7 @@ fun App( val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory) val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory) val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory) + val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory) val context = LocalContext.current val activity = context as? ComponentActivity @@ -170,6 +183,11 @@ fun App( snackbarHostState.showSnackbar(message) } } + launch { + concordViewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } + } launch { profileCache.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) @@ -237,7 +255,7 @@ fun App( ), entryProvider = entryProvider { entry { - HomeScreen(accountViewModel, chatViewModel) + HomeScreen(accountViewModel, chatViewModel, concordViewModel) } entry { RequestListScreen(chatViewModel) @@ -297,6 +315,36 @@ fun App( entry { SettingsScreen(settingsViewModel) } + entry { + CommunitiesScreen(concordViewModel) + } + entry { key -> + JoinCommunityScreen(concordViewModel, key.link) + } + entry { key -> + CommunityScreen(key.communityId, concordViewModel) + } + entry { key -> + val factory = remember(key) { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return ChannelScreenViewModel( + key.communityId, + key.channelId, + accountRepository, + concordRepository + ) as T + } + } + } + ChannelScreen( + viewModel( + key = "${key.communityId}:${key.channelId}", + factory = factory + ) + ) + } } ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 86b9171..45e64a7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.MainScope import su.reya.coop.nostr.NostrManager import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.ChatRepository +import su.reya.coop.repository.ConcordRepository import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.SettingsRepository import su.reya.coop.viewmodel.ProfileCache @@ -52,6 +53,10 @@ class MainActivity : ComponentActivity() { ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope) } + private val concordRepository by lazy { + ConcordRepository(NostrManager.instance, scope) + } + override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> throwable.printStackTrace() @@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() { profileCache = profileCache, accountRepository = accountRepository, chatRepository = chatRepository, + concordRepository = concordRepository, settingsRepository = settingsRepository, connectivityMonitor = connectivityMonitor, ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index cc3b4e0..cc92896 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -61,4 +61,16 @@ sealed interface Screen : NavKey { @Serializable data object Settings : Screen + + @Serializable + data object Communities : Screen + + @Serializable + data class Community(val communityId: String) : Screen + + @Serializable + data class Channel(val communityId: String, val channelId: String) : Screen + + @Serializable + data class JoinCommunity(val link: String? = null) : Screen } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 7f29360..63aa6b6 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -102,17 +102,20 @@ import su.reya.coop.RoomKind import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.ago +import su.reya.coop.concord.parseInviteLink import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.uiStateFlow import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel +import su.reya.coop.viewmodel.ConcordViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun HomeScreen( accountViewModel: AccountViewModel, - chatViewModel: ChatViewModel + chatViewModel: ChatViewModel, + concordViewModel: ConcordViewModel, ) { val context = LocalContext.current val navigator = LocalNavigator.current @@ -166,16 +169,22 @@ fun HomeScreen( LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> - runCatching { PublicKey.parse(result) } - .onSuccess { pubkey -> - try { - val roomId = chatViewModel.createChatRoom(listOf(pubkey)) - navigator.navigate(Screen.Chat(roomId)) - } catch (e: Exception) { - e.message?.let { snackbarHostState.showSnackbar(it) } + // A Concord invite is a link, not a npub, so it is routed to the Join screen instead of + // being force-fed to PublicKey.parse. + if (parseInviteLink(result) != null) { + navigator.navigate(Screen.JoinCommunity(result)) + } else { + runCatching { PublicKey.parse(result) } + .onSuccess { pubkey -> + try { + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) + navigator.navigate(Screen.Chat(roomId)) + } catch (e: Exception) { + e.message?.let { snackbarHostState.showSnackbar(it) } + } } - } - .onFailure { e -> println("Failed to parse QR: ${e.message}") } + .onFailure { e -> println("Failed to parse QR: ${e.message}") } + } // Clear the nav state qrScanResult.clear() } @@ -473,7 +482,8 @@ fun HomeScreen( BottomMenuList( onDismiss = dismissAndRun, accountViewModel = accountViewModel, - chatViewModel = chatViewModel + chatViewModel = chatViewModel, + concordViewModel = concordViewModel ) } } @@ -788,12 +798,14 @@ fun BottomMenuList( onDismiss: (suspend () -> Unit) -> Unit, accountViewModel: AccountViewModel, chatViewModel: ChatViewModel, + concordViewModel: ConcordViewModel, ) { val navigator = LocalNavigator.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, "Contact List" to { navigator.navigate(Screen.ContactList) }, + "Communities (beta)" to { navigator.navigate(Screen.Communities) }, "Relay Management" to { navigator.navigate(Screen.Relay) }, "Settings" to { navigator.navigate(Screen.Settings) } ) @@ -824,6 +836,8 @@ fun BottomMenuList( accountViewModel.logout(onLogout = { accountViewModel.resetInternalState() chatViewModel.resetInternalState() + // Concord's keys are identity-scoped, so they leave with the identity. + concordViewModel.resetInternalState() }) } }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt index e1300a5..49ec805 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt @@ -38,8 +38,8 @@ fun ChatInput( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, - onUpload: () -> Unit, - onMicClick: () -> Unit + onUpload: (() -> Unit)? = null, + onMicClick: (() -> Unit)? = null ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -60,12 +60,16 @@ fun ChatInput( capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Default ), - leadingIcon = { - IconButton(onClick = onUpload) { - Icon( - painter = painterResource(Res.drawable.ic_add_circle), - contentDescription = "Upload", - ) + leadingIcon = if (onUpload == null) { + null + } else { + { + IconButton(onClick = onUpload) { + Icon( + painter = painterResource(Res.drawable.ic_add_circle), + contentDescription = "Upload", + ) + } } }, ) @@ -75,9 +79,21 @@ fun ChatInput( transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) }, label = "send_mic_transition" ) { isNotEmpty -> - if (isNotEmpty) { + // Nothing to send and nowhere to dictate to: the send button stays, greyed out. + if (!isNotEmpty && onMicClick != null) { + FilledTonalIconButton( + onClick = onMicClick, + modifier = Modifier.size(56.dp), + ) { + Icon( + painter = painterResource(Res.drawable.ic_audio), + contentDescription = "Speech to Text" + ) + } + } else { IconButton( onClick = onSend, + enabled = isNotEmpty, modifier = Modifier.size(56.dp), colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, @@ -89,16 +105,6 @@ fun ChatInput( contentDescription = "Send" ) } - } else { - FilledTonalIconButton( - onClick = onMicClick, - modifier = Modifier.size(56.dp), - ) { - Icon( - painter = painterResource(Res.drawable.ic_audio), - contentDescription = "Speech to Text" - ) - } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt new file mode 100644 index 0000000..96278f4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt @@ -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 { + val profileCache = LocalProfileCache.current + val pubkey = remember(authorHex) { runCatching { PublicKey.parse(authorHex) }.getOrNull() } + val profile by remember(pubkey) { + pubkey?.let { profileCache.getMetadata(it) } ?: flowOf(null) + }.collectAsStateWithLifecycle(null) + + return pubkey to profile +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunitiesScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunitiesScreen.kt new file mode 100644 index 0000000..73c0541 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunitiesScreen.kt @@ -0,0 +1,185 @@ +package su.reya.coop.screens.communities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import coop.composeapp.generated.resources.ic_plus +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavigator +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen +import su.reya.coop.displayName +import su.reya.coop.viewmodel.ConcordViewModel + +/** + * Every Community this device has joined, plus any Direct Invite still waiting. + * + * There is no file upload, no message search and no per-Community settings page — v1 is the read + * path and sending, and the screen says so rather than pretending otherwise. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CommunitiesScreen(concordViewModel: ConcordViewModel) { + val navigator = LocalNavigator.current + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + + val communities by concordViewModel.communities.collectAsStateWithLifecycle() + val directInvites by concordViewModel.directInvites.collectAsStateWithLifecycle() + val isReady by concordViewModel.isReady.collectAsStateWithLifecycle() + + val sorted = remember(communities) { communities.sortedBy { it.displayName().lowercase() } } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + text = "Communities", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = { navigator.goBack() }) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + ) + }, + floatingActionButton = { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + PlainTooltip { Text("Join a community") } + }, + state = rememberTooltipState(), + ) { + ExtendedFloatingActionButton( + onClick = { navigator.navigate(Screen.JoinCommunity()) }, + expanded = false, + icon = { + Icon( + painter = painterResource(Res.drawable.ic_plus), + contentDescription = "Join a community" + ) + }, + text = { Text("Join") }, + ) + } + }, + content = { innerPadding -> + when { + !isReady && sorted.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } + + sorted.isEmpty() && directInvites.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + CommunityEmptyState( + title = "No communities yet", + subtitle = "Join one with an invite link.", + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + items(directInvites, key = { it.communityId }) { invite -> + DirectInviteCard( + invite = invite, + onJoin = { + scope.launch { + // A Direct Invite already carries its bundle, so there is + // nothing to fetch — only to validate and accept. + val preview = concordViewModel.previewDirectInvite(invite) + val joined = concordViewModel.join(preview) + if (joined != null) { + navigator.navigate( + Screen.Community(joined.membership.communityId) + ) + } + } + }, + onDismiss = { + concordViewModel.dismissDirectInvite(invite.communityId) + }, + ) + } + + items(sorted, key = { it.membership.communityId }) { state -> + CommunityRow( + state = state, + onClick = { + navigator.navigate( + Screen.Community(state.membership.communityId) + ) + }, + ) + } + } + } + } + }, + ) +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityComponents.kt new file mode 100644 index 0000000..14fd74f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityComponents.kt @@ -0,0 +1,254 @@ +package su.reya.coop.screens.communities + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_communities +import coop.composeapp.generated.resources.ic_lock +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.concord.CommunityInvite +import su.reya.coop.concord.CommunityState +import su.reya.coop.concord.ConcordChannel +import su.reya.coop.displayName +import su.reya.coop.shared.Avatar +import su.reya.coop.unreadTotal + +/** + * Rows and small blocks shared by the Communities screens, mirroring `HomeScreen`'s `ChatRoom` and + * `ContactListItem`: `ListItem` for the top level, `SegmentedListItem` inside a group. + */ + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CommunityRow(state: CommunityState, onClick: () -> Unit) { + val unread = state.unreadTotal() + + ListItem( + modifier = Modifier.clickable(onClick = onClick), + leadingContent = { + BadgedBox( + badge = { + if (unread > 0) { + Badge { Text(unread.toString()) } + } + } + ) { + // Concord icons are encrypted blobs v1 never fetches, so this is always the placeholder. + Avatar(picture = null, description = state.displayName()) + } + }, + headlineContent = { + Text( + text = state.displayName(), + style = MaterialTheme.typography.titleMediumEmphasized.copy( + fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text( + text = channelCountLabel(state.channels.size), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface) + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChannelRow(channel: ConcordChannel, index: Int, total: Int, onClick: () -> Unit) { + val unread = channel.unreadCount + + SegmentedListItem( + onClick = onClick, + shapes = ListItemDefaults.segmentedShapes(index = index, count = total), + leadingContent = { + BadgedBox( + badge = { + if (unread > 0) { + Badge { Text(unread.toString()) } + } + } + ) { + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + if (channel.private) { + Icon( + painter = painterResource(Res.drawable.ic_lock), + contentDescription = "Private", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.outline, + ) + } else { + Text( + text = "#", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + }, + content = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = channel.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // A Private Channel with no key is listable but unreadable: the row says so rather + // than opening an empty room and looking broken. + if (!channel.hasKey) { + Text( + text = "No key", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + ) +} + +/** + * A Direct Invite (CORD-05 §6) waiting to be accepted. It arrived giftwrapped to this npub, so it is + * shown on the Communities list rather than in any chat. + */ +@Composable +fun DirectInviteCard(invite: CommunityInvite, onJoin: () -> Unit, onDismiss: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Invited to a community", + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = invite.name ?: "Untitled community", + style = MaterialTheme.typography.titleMediumEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.size(2.dp)) + Text( + text = shortCommunityId(invite.communityId), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDismiss) { Text("Dismiss") } + Button(onClick = onJoin) { Text("Join") } + } + } + } +} + +/** The app's empty-state convention: two centred lines, with the feature's mark above them. */ +@Composable +fun CommunityEmptyState(title: String, subtitle: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painterResource(Res.drawable.ic_communities), + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.outlineVariant, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleLargeEmphasized.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } +} + +/** + * Says out loud what v1 does not do yet. Concord's authority is a signed roster every client folds + * (CORD-04), and this build folds none of it — a stale claim of moderation would be worse than none. + */ +@Composable +fun BetaNotice() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(Res.drawable.ic_lock), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = "Beta. Messages, channels and invites work. Roles, kicks and bans are not " + + "enforced by this build yet — only by the other clients in the community.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +private fun channelCountLabel(count: Int): String = + if (count == 1) "1 channel" else "$count channels" + +/** A Community's identity is a hash, so show enough of it to tell two apart. */ +internal fun shortCommunityId(communityId: String): String = + if (communityId.length <= 16) communityId else communityId.take(16) + "..." diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityScreen.kt new file mode 100644 index 0000000..c2895e4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/CommunityScreen.kt @@ -0,0 +1,171 @@ +package su.reya.coop.screens.communities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavigator +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen +import su.reya.coop.displayName +import su.reya.coop.viewmodel.ConcordViewModel + +/** + * One Community's Channels, split public and private the way the protocol splits them: a Public + * Channel's key derives from `community_root`, so anyone in the Community can read it, while a + * Private one is only listable to those an invite or a rekey handed a key to (CORD-03 §1). + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CommunityScreen(communityId: String, concordViewModel: ConcordViewModel) { + val navigator = LocalNavigator.current + val snackbarHostState = LocalSnackbarHostState.current + + val communities by concordViewModel.communities.collectAsStateWithLifecycle() + val state = remember(communities, communityId) { + communities.firstOrNull { it.membership.communityId == communityId } + } + + if (state == null) { + // Reachable if the Community was dropped underneath this screen (a logout while it was open), + // so it keeps a way back rather than dead-ending here. + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + topBar = { + TopAppBar( + title = { + Text( + text = "Community", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = { navigator.goBack() }) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Text( + text = "This community is not on this device.", + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + return + } + + val (privateChannels, publicChannels) = remember(state.channels) { + state.channels.partition { it.private } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + text = state.displayName(), + style = MaterialTheme.typography.titleMediumEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = { navigator.goBack() }) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + ) + }, + content = { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + item { BetaNotice() } + + itemsIndexed(publicChannels) { index, channel -> + ChannelRow( + channel = channel, + index = index, + total = publicChannels.size, + onClick = { + navigator.navigate(Screen.Channel(communityId, channel.idHex)) + }, + ) + } + + itemsIndexed(privateChannels) { index, channel -> + ChannelRow( + channel = channel, + index = index, + total = privateChannels.size, + onClick = { + navigator.navigate(Screen.Channel(communityId, channel.idHex)) + }, + ) + } + + if (state.channels.isEmpty()) { + item { + Text( + text = "No channels yet. They arrive with the community's metadata.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + }, + ) +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/JoinCommunityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/JoinCommunityScreen.kt new file mode 100644 index 0000000..994d9aa --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/JoinCommunityScreen.kt @@ -0,0 +1,237 @@ +package su.reya.coop.screens.communities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import coop.composeapp.generated.resources.ic_scanner +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavigator +import su.reya.coop.LocalScanResult +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen +import su.reya.coop.concord.InvitePreview +import su.reya.coop.viewmodel.ConcordViewModel + +/** + * Paste or scan an invite link, see what joining would mean, then join. + * + * The preview is deliberately a separate step: a link is fetched and decrypted, but nothing is + * subscribed and no presence is announced until the user accepts (CORD-05 §1), so backing out here + * leaves no trace on any relay. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun JoinCommunityScreen(concordViewModel: ConcordViewModel, initialLink: String? = null) { + val navigator = LocalNavigator.current + val qrScanResult = LocalScanResult.current + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + + var link by remember(initialLink) { mutableStateOf(initialLink.orEmpty()) } + var preview by remember { mutableStateOf(null) } + var isBusy by remember { mutableStateOf(false) } + + LaunchedEffect(qrScanResult.content) { + qrScanResult.content?.let { scanned -> + link = scanned + preview = null + qrScanResult.clear() + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + text = "Join a community", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = { navigator.goBack() }) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { navigator.navigate(Screen.Scan) }) { + Icon( + painter = painterResource(Res.drawable.ic_scanner), + contentDescription = "Scanner" + ) + } + }, + ) + }, + content = { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = link, + onValueChange = { + link = it + // Any edit invalidates what was fetched for the old text. + preview = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Invite link") }, + placeholder = { Text("https://.../invite/...") }, + maxLines = 4, + ) + + Button( + onClick = { + scope.launch { + isBusy = true + preview = concordViewModel.previewInvite(link) + isBusy = false + } + }, + enabled = link.isNotBlank() && !isBusy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Look it up") + } + + preview?.let { shown -> + InvitePreviewCard( + preview = shown, + isBusy = isBusy, + onJoin = { + scope.launch { + isBusy = true + val joined = concordViewModel.join(shown) + isBusy = false + if (joined != null) { + // Replace this screen with the Community it just joined. + navigator.goBack() + navigator.navigate( + Screen.Community(joined.membership.communityId) + ) + } + } + }, + ) + } + } + }, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun InvitePreviewCard(preview: InvitePreview, isBusy: Boolean, onJoin: () -> Unit) { + val invite = preview.invite + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = invite.name ?: "Untitled community", + style = MaterialTheme.typography.titleMediumEmphasized, + ) + Text( + text = shortCommunityId(invite.communityId), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = plural(invite.channels.size, "channel"), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = plural(preview.relays.size, "relay"), + style = MaterialTheme.typography.bodyMedium, + ) + + // Everything that would stop this invite from being accepted, in plain English. + preview.problems.forEach { problem -> Refusal(problem) } + if (preview.expired) Refusal("This invite has expired.") + if (preview.alreadyJoined) { + Text( + text = "You are already in this community.", + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.size(8.dp)) + Button( + onClick = onJoin, + enabled = preview.joinable && !isBusy, + modifier = Modifier.fillMaxWidth(), + ) { + if (isBusy) { + LoadingIndicator(modifier = Modifier.size(20.dp)) + } else { + Text(if (preview.alreadyJoined) "Rejoin" else "Join") + } + } + } + } +} + +@Composable +private fun Refusal(message: String) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) +} + +private fun plural(count: Int, noun: String): String = + if (count == 1) "1 $noun" else "$count ${noun}s" diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Community.kt b/shared/src/commonMain/kotlin/su/reya/coop/Community.kt new file mode 100644 index 0000000..f815b54 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Community.kt @@ -0,0 +1,46 @@ +package su.reya.coop + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime +import su.reya.coop.concord.CommunityState +import su.reya.coop.concord.ConcordMessage +import kotlin.time.Clock + +/** Control metadata first, then the snapshot the invite carried, then a last-resort label. */ +fun CommunityState.displayName(): String { + val name = meta?.name?.sanitizeName()?.takeIf { it.isNotBlank() } + ?: membership.name?.sanitizeName()?.takeIf { it.isNotBlank() } + return name ?: "Untitled community" +} + +/** Every Channel's badge added up, for the Community row. */ +fun CommunityState.unreadTotal(): Int = channels.sumOf { it.unreadCount } + +/** `HH:mm`, local time. */ +fun ConcordMessage.timeLabel(): String { + val time = createdAt.toLocalDateTime(TimeZone.currentSystemDefault()) + val hour = time.hour.toString().padStart(2, '0') + val minute = time.minute.toString().padStart(2, '0') + return "$hour:$minute" +} + +/** `Today` / `Yesterday` / `DD/MM/YY`, matching [formatAsGroup] for DMs. */ +fun ConcordMessage.dayLabel(): String { + val zone = TimeZone.currentSystemDefault() + val date = createdAt.toLocalDateTime(zone).date + val today = Clock.System.now().toLocalDateTime(zone).date + + return when (date) { + today -> "Today" + today.minus(1, DateTimeUnit.DAY) -> "Yesterday" + else -> { + val day = date.day.toString().padStart(2, '0') + val month = date.month.number.toString().padStart(2, '0') + val year = date.year.toString().takeLast(2) + "$day/$month/$year" + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt index 0120050..7b3a21a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt @@ -4,36 +4,7 @@ import kotlinx.serialization.decodeFromString import rust.nostr.sdk.UnsignedEvent import kotlin.time.Instant -/** - * Folding the Control Plane (CORD-04 §1). - * - * The Control Plane carries the Community's authoritative state as **editions**: each is a - * `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this - * edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole - * chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so. - * - * v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`). - * - * ## What v1 does not do - * - * **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the - * owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could - * publish a forged metadata or Channel edition and v1 would display it. - * - * That gap is bounded rather than open — only the owner and staff hold `control_root` (CORD-02 §2), - * and the spec itself calls that secret "a spam gate, never authority" — but it is real. It is the - * reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and - * `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly - * outranks the entity it edits. - */ object ConcordControl { - - /** - * Parses one Control rumor into an edition, or null when it is not a foldable edition. - * - * A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1 - * does not implement dissolution, so treating it as an ordinary edition would misread it. - */ fun editionOf(rumor: UnsignedEvent): ControlEdition? { if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null @@ -45,7 +16,6 @@ object ConcordControl { if (eidHex.hex32() == null) return null val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null - // CORD-04: versions climb from 1. A zero version has no place in the chain. if (version == 0uL) return null val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content() @@ -63,12 +33,6 @@ object ConcordControl { ) } - /** - * Projects the editions into the metadata and Channel list v1 renders. - * - * Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a - * broken chain on one Channel never takes the Community's metadata down with it. - */ fun fold(editions: List, communityIdHex: String): ControlFold { var meta: CommunityMeta? = null val channels = mutableMapOf() @@ -90,16 +54,6 @@ object ConcordControl { return ControlFold(meta = meta, channels = channels) } - /** - * Picks the highest version whose `ep` chain reaches all the way back to version 1. - * - * "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down - * from the top and take the first version we can actually verify. A missing predecessor or a - * broken link disqualifies that version, not the entity. - * - * Ties on the same version break on the **lower rumor id**, never on `created_at`, so two - * clients folding the same two candidates always agree. - */ private fun winner(group: List): ControlEdition? { val byVersion = group .groupBy { it.version } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt index 5a113f9..a72ab7f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt @@ -8,29 +8,6 @@ import rust.nostr.sdk.Keys import rust.nostr.sdk.PublicKey import rust.nostr.sdk.SecretKey -/** - * Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A). - * - * Everything here is frozen by the spec, and a single wrong byte breaks interop silently - * rather than loudly — so each function quotes the CORD section that governs it, and 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( ikm: ByteArray, info: ByteArray, @@ -39,18 +16,13 @@ fun hkdfSha256( ): ByteArray { require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" } - // Extract: PRK = HMAC-SHA256(salt, IKM). Note IKM is the HMAC *message*, not the key. - // Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen - // (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the - // two are literally the same key. Substituting is exact, not a workaround; the RFC's own - // zero-length-salt vector was checked against it. val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString() val prk = ikm.toByteString().hmacSha256(saltKey) - // Expand: T(n) = HMAC-SHA256(PRK, T(n-1) | info | n), counter being one octet. val out = Buffer() var t = ByteString.EMPTY var counter = 1 + while (out.size < length) { t = Buffer() .write(t) @@ -61,19 +33,10 @@ fun hkdfSha256( out.write(t) counter++ } + return out.readByteArray(length.toLong()) } -/** - * Builds the HKDF `info` for a Concord label (CORD-02 A.1): - * - * ``` - * info = utf8(label) | 0x00 | id[32] | epoch_be[8] // epoch omitted for labels marked "—" - * ``` - * - * [id] is always present and always 32 bytes, all-zeroes where a label has no meaningful - * id. The epoch is the only omittable field. - */ fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray { require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" } return Buffer().apply { @@ -87,23 +50,6 @@ fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray { /** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */ data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey) -/** - * The secret-key material of a plane's group key: CORD-02 A.2's `group_key` up to and - * including A.3's `scalar_normalize`. - * - * ``` - * info = hkdfInfo(label, id, epoch) - * seed = hkdf(secret, info) - * while (!isValidScalar(seed)) { info = info | counter++; seed = hkdf(secret, info) } - * ``` - * - * A.3 only bites when the HKDF output is not a secp256k1 scalar, which is ~2⁻¹²⁸ rare, so - * [isValid] exists as a seam for tests; production callers pass the default. - * - * This is deliberately split from [groupKey]: it is pure byte manipulation and so is - * unit-testable, whereas the secp256k1 half needs the nostr SDK, whose native library - * cannot be loaded by a host JVM unit test (see PLAN.md §13.2). - */ fun groupSeed( label: String, secret: ByteArray, @@ -112,62 +58,47 @@ fun groupSeed( isValid: (ByteArray) -> Boolean = ::isValidScalar, ): ByteArray { val base = hkdfInfo(label, id, epoch) - var counter = -1 // -1 means "no counter byte", i.e. the first attempt + var counter = -1 + while (counter <= 255) { val info = if (counter < 0) base else base + byteArrayOf(counter.toByte()) val seed = hkdfSha256(secret, info) if (isValid(seed)) return seed - counter++ // A.3: the counter starts at 0 on the first retry + counter++ } + error("Concord group_key: scalar_normalize exhausted for label $label") } -/** - * A secp256k1 secret key is any integer in `[1, n-1]`, so CORD-02 A.3's validity test rejects - * exactly the all-zeroes seed and any seed not below the group order. - */ fun isValidScalar(seed: ByteArray): Boolean { if (seed.size != 32) return false var anyNonZero = false + for (byte in seed) { if (byte != 0.toByte()) { anyNonZero = true break } } + if (!anyNonZero) return false for (i in 0 until 32) { val candidate = seed[i].toInt() and 0xff val order = SECP256K1_ORDER[i].toInt() and 0xff if (candidate != order) return candidate < order } - return false // exactly n, also out of range + + return false } private val SECP256K1_ORDER = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes() -/** - * `group_key` (CORD-02 A.2): a plane's keypair, `(scalar_normalize(seed), xonly_pubkey(sk))`. - * - * The `conv_key` of A.2 needs no implementation of its own — it *is* the NIP-44 conversation - * key, which the SDK derives from the pair returned here, so nothing here hand-rolls ECDH. - */ fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey { val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch)) return GroupKey(secretKey, Keys(secretKey).publicKey()) } -/** - * `community_id` (CORD-02 A.4), which is also the community's self-certification: - * - * ``` - * community_id = sha256( utf8("concord/community") | owner_xonly[32] | owner_salt[32] ) - * ``` - * - * Note this is a plain SHA-256 commitment with **no** `0x00` separator and no length - * prefix — it is deliberately *not* the HKDF construction of A.1, despite looking like it. - */ fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray { require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" } require(ownerSalt.size == 32) { "owner_salt must be 32 bytes, was ${ownerSalt.size}" } @@ -178,14 +109,6 @@ fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray { }.readByteString().sha256().toByteArray() } -/** - * `prevcommit` (CORD-02 A.8) — the commitment to the previous epoch's key, published when - * an epoch rolls so that members can verify the rotation chained from what they held: - * - * ``` - * prevcommit = sha256( utf8("concord/epoch-key-commitment") | prev_epoch_be[8] | prev_key[32] ) - * ``` - */ fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray { require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" } return Buffer().apply { @@ -195,22 +118,6 @@ fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray { }.readByteString().sha256().toByteArray() } -/** - * `edition_hash` (CORD-02 A.8) — links a Control edition to its predecessor, so a client - * folding the Control plane can detect a rewritten chain: - * - * ``` - * edition_hash = sha256( - * len64(label) | label // label = ConcordLabel.EDITION_HASH - * | entity_id[32] - * | version_be[8] - * | (prev ? 0x01 | prev[32] : 0x00 | zero[32]) - * | len64(content) | content ) // content bytes verbatim, never re-serialized - * ``` - * - * [content] must be the exact bytes that were signed — re-serializing the JSON would change - * the hash. - */ fun editionHash(entityId: ByteArray, version: ULong, prev: ByteArray?, content: ByteArray): ByteArray { require(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" } require(prev == null || prev.size == 32) { "prev must be 32 bytes" } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt index 43831e8..d41aede 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt @@ -5,23 +5,6 @@ import rust.nostr.sdk.Nip19Coordinate import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.nip44Decrypt -/** - * CORD-05: redeeming an invite. - * - * Two ways to be handed the keys, one bundle: - * - * - **Public link** — `$BASE/invite/#`. The naddr names where the encrypted - * bundle sits on relays; the fragment carries an off-network unlock token and never reaches a - * server. The bundle is fetched, then decrypted with a key derived from the token. - * - **Direct Invite** — the same bundle, giftwrapped straight to an npub. Nothing to fetch. That - * path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`. - * - * Minting is out of v1's scope, so this file only decodes. - * - * A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the - * strength of what the bundle claims (CORD-05 §1). - */ - /** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */ internal object ConcordRelayDictionary { val STOCK = listOf( @@ -67,13 +50,6 @@ private const val MAX_COMMUNITY_RELAYS = 5 /** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */ private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL) -/** - * Decodes a link into its locator and fragment, or null when it is not a Concord invite. - * - * The base domain is deliberately ignored — CORD-05 §2 makes the base interchangeable and says any - * client recognizing an invite must respect the naddr and fragment verbatim — so only the last path - * segment and the `#` fragment are read. - */ fun parseInviteLink(text: String): ParsedInvite? { val trimmed = text.trim() val hash = trimmed.indexOf('#') @@ -93,16 +69,6 @@ fun parseInviteLink(text: String): ParsedInvite? { ) } -/** - * CORD-05 §3's fragment layout: - * - * ``` - * [version=4][flags][relays?][token:16] - * ``` - * - * With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries, - * each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL. - */ fun decodeInviteFragment(fragment: String): InviteFragment? { val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null // Two header bytes plus the token at minimum. @@ -112,8 +78,7 @@ fun decodeInviteFragment(fragment: String): InviteFragment? { if (reader.byte() != FRAGMENT_VERSION) return null val flags = reader.byte() ?: return null - // The stock flag selects the whole dictionary, so nothing extra is carried and the cap on - // explicit entries does not apply to it. + val relays = if (flags and FLAG_STOCK_RELAYS != 0) { ConcordRelayDictionary.STOCK } else { @@ -142,41 +107,24 @@ fun decodeInviteFragment(fragment: String): InviteFragment? { ) } -/** - * `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one - * thing — decrypting the bundle — and nothing else. - * - * Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's - * `nip44_encrypt(bundle_key, …)` is the same self-ECDH conversation key every other Concord - * `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key - * is what lets the existing SDK do the encryption instead of hand-rolling NIP-44. - */ fun inviteBundleKey(tokenHex: String): GroupKey? = tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) } -/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */ fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? { val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull() ?: return null return runCatching { concordJson.decodeFromString(plaintext) }.getOrNull() } -/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */ fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString() -/** - * CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so - * the preview can explain itself rather than silently refusing. - * - * The first one is the load-bearing one: `community_id == sha256("concord/community" ‖ owner ‖ - * owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community. - */ fun CommunityInvite.problems(): List { val problems = mutableListOf() val ownerBytes = owner.hex32() val saltBytes = ownerSalt.hex32() val rootBytes = communityRoot.hex32() + if (ownerBytes == null) problems += "The invite's owner key is malformed" if (saltBytes == null) problems += "The invite's owner salt is malformed" if (rootBytes == null) problems += "The invite's community key is malformed" @@ -186,12 +134,14 @@ fun CommunityInvite.problems(): List { problems += "The invite does not prove its community id" } } + if (communityId.hex32() == null) problems += "The invite's community id is malformed" if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed" if (channels.size > MAX_INVITE_CHANNELS) { problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)" } + channels.forEachIndexed { index, channel -> if (channel.id.hex32() == null) problems += "Channel $index has a malformed id" if (channel.key.hex32() == null) problems += "Channel $index has a malformed key" @@ -200,20 +150,13 @@ fun CommunityInvite.problems(): List { return problems } -/** CORD-05 §1: an expired bundle still previews, but joining refuses. */ fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs -/** - * The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays, - * used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the - * bundle's copy is the join-time snapshot of the real set (CORD-02 §6). - */ fun CommunityInvite.relaySet(fallback: List = emptyList()): List { val source = relays.ifEmpty { fallback } return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS) } -/** Turns a validated bundle into the membership we persist. */ fun CommunityInvite.toMembership(relays: List): Membership = Membership( communityId = communityId.lowercase(), owner = owner.lowercase(), @@ -230,14 +173,11 @@ fun CommunityInvite.toMembership(relays: List): Membership = Membership( key = channel.key.lowercase(), epoch = channel.epoch, name = channel.name, - // A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the - // only reading the bundle supports. The Control fold overrides this for display. private = !channel.key.equals(communityRoot, ignoreCase = true), ) }, ) -/** Reads the fragment's length-prefixed fields, refusing to run past the end. */ private class FragmentReader(private val bytes: ByteArray) { var index = 0 private set diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt index 69c7d6e..416b2d8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt @@ -1,21 +1,5 @@ package su.reya.coop.concord -/** - * Frozen constants from the Concord specification. - * - * Concord is defined by the CORD documents (github.com/concord-protocol/concord); the - * numbers, labels and permission bits below are normative and must match byte for byte - * or nothing interoperates. They are collected in this one file so that a spec revision - * is a one-file change and so that no literal kind number ever appears at a call site. - * - * References are to the CORD section that defines each value. See `PLAN.md` Appendix A - * for the same tables with prose. - */ - -/** - * Event kinds, either the outer wrap or the inner rumor it carries (CORD-01, CORD-02 §5, - * CORD-02 Appendix B). - */ object ConcordKind { /** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */ const val WRAP = 1059 @@ -129,10 +113,6 @@ object ConcordVsk { const val PIN_LIST = 11 } -/** - * Permission bits (CORD-04 §3). Rank ordering is separate: `position` orders authority and - * **lower is higher**, with the owner at position 0. - */ object ConcordPermission { const val MANAGE_ROLES = 1 shl 0 const val MANAGE_CHANNELS = 1 shl 1 @@ -148,12 +128,8 @@ object ConcordPermission { const val VIEW_AUDIT_LOG = 1 shl 8 const val MENTION_EVERYONE = 1 shl 9 - // 1 shl 10 is reserved. - const val PIN_MESSAGES = 1 shl 11 - // 1 shl 12 is reserved. - /** * Staff = anyone holding a staff bit, plus the owner. Staff are the ones who hold * `control_root` and can therefore write to the Control plane. @@ -162,10 +138,6 @@ object ConcordPermission { BAN or CREATE_INVITE or PIN_MESSAGES } -/** - * HKDF label registry (CORD-02 Appendix A.6). The label is the *first* field of the HKDF - * `info` (see `hkdfInfo`), so these strings are on the wire and must not be edited. - */ object ConcordLabel { /** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */ const val CHANNEL = "concord/channel" diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt index 003eabf..65cd65e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.serialization.decodeFromString import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.Event import rust.nostr.sdk.Filter @@ -22,33 +21,7 @@ import kotlin.concurrent.Volatile import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -/** - * Concord's read path: which planes we hold keys for, what we fold out of them, and how an invite - * turns into membership. - * - * ## Why this lives on [Nostr] - * - * The client has exactly **one** notification pump. `client.notifications()` is called once, inside - * [Nostr.handleNotifications], and a second consumer would silently split the stream — so Concord - * routes through that pump rather than subscribing to its own (see [isPlaneAddress] and [onInboxRumor]). - * - * ## The two phases - * - * [restore] is local and fast: it reads memberships from storage and folds whatever the Control - * plane has already cached, so routing is meaningful before anything hits the network. [sync] is - * the network half: connect the Community's relays and subscribe to its plane addresses. The pump - * calls them in that order, so the first event that arrives already knows where it belongs. - */ class ConcordManager(private val nostr: Nostr) { - - /** - * Concord's plane pubkeys, keyed by hex. Every incoming `kind 1059` is routed by author: a - * Concord wrap is signed by a plane's *derived* stream key and can never be read by the NIP-17 - * path, which assumes an ephemeral author and a `p`-tagged recipient. - * - * Replaced wholesale rather than mutated, and read from the notification pump's thread while a - * worker coroutine rewrites it, so the reference is volatile. - */ @Volatile private var planes: Map = emptyMap() @@ -81,6 +54,13 @@ class ConcordManager(private val nostr: Nostr) { private val _directInvites = MutableStateFlow>(emptyList()) val directInvites: StateFlow> = _directInvites.asStateFlow() + /** + * True once [restore] has run. An empty [memberships] means the same thing before and after it, + * so a screen needs this to tell "joined nothing" from "not loaded yet". + */ + private val _restored = MutableStateFlow(false) + val restored: StateFlow = _restored.asStateFlow() + private var store: ConcordStore? = null /** @@ -94,10 +74,6 @@ class ConcordManager(private val nostr: Nostr) { store = ConcordStore(storage, nostr) } - // ----------------------------------------------------------------------------------------- - // Routing - // ----------------------------------------------------------------------------------------- - /** True when a wrap's author is one of our planes' stream keys. */ fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex) @@ -138,7 +114,8 @@ class ConcordManager(private val nostr: Nostr) { fun onInboxRumor(rumor: UnsignedEvent): Boolean { if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false - val invite = runCatching { concordJson.decodeFromString(rumor.content()) }.getOrNull() + val invite = + runCatching { concordJson.decodeFromString(rumor.content()) }.getOrNull() if (invite == null) { println("Concord: a direct invite arrived but its bundle could not be read") return true @@ -149,16 +126,17 @@ class ConcordManager(private val nostr: Nostr) { } _directInvites.update { current -> - if (current.any { it.communityId.equals(invite.communityId, ignoreCase = true) }) current + if (current.any { + it.communityId.equals( + invite.communityId, + ignoreCase = true + ) + }) current else current + invite } return true } - // ----------------------------------------------------------------------------------------- - // Lifecycle - // ----------------------------------------------------------------------------------------- - /** Local half of startup: load memberships, fold the cached Control plane, index the planes. */ suspend fun restore() { val store = store ?: return @@ -175,6 +153,31 @@ class ConcordManager(private val nostr: Nostr) { ) } 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. */ @@ -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 * would mean. Nothing is joined, nothing is subscribed and no presence is announced here * (CORD-05 §1: a bundle is passive until the user accepts). */ suspend fun previewInvite(link: String): InvitePreview { - val parsed = parseInviteLink(link) ?: throw IllegalArgumentException("That is not a Concord invite link") - val bundleKey = inviteBundleKey(parsed.fragment.tokenHex) - ?: throw IllegalArgumentException("The invite link's token is malformed") val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready") - // The fragment's bootstrap relays only have to *find* the bundle; the bundle then carries - // the Community's real relay set (CORD-05 §3). + val parsed = parseInviteLink(link) + ?: throw IllegalArgumentException("That is not a Concord invite link") + + val bundleKey = inviteBundleKey(parsed.fragment.tokenHex) + ?: throw IllegalArgumentException("The invite link's token is malformed") + + val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays) .mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() } .distinct() + if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from") relays.forEach { relay -> @@ -223,7 +225,10 @@ class ConcordManager(private val nostr: Nostr) { .identifier(parsed.identifier) val bundle = client - .fetchEvents(ReqTarget.manual(relays.associateWith { listOf(filter) }), timeout = 8.seconds) + .fetchEvents( + ReqTarget.manual(relays.associateWith { listOf(filter) }), + timeout = 8.seconds + ) .toVec() .firstOrNull() ?: throw IllegalStateException("No invite bundle was found at that link") @@ -239,7 +244,18 @@ class ConcordManager(private val nostr: Nostr) { } /** The same preview for a Direct Invite, which arrived with no link to fetch. */ - fun previewDirectInvite(invite: CommunityInvite): InvitePreview = preview(invite, link = null, fallbackRelays = emptyList()) + fun previewDirectInvite(invite: CommunityInvite): InvitePreview = + preview(invite, link = null, fallbackRelays = emptyList()) + + /** + * Forgets a Direct Invite the user declined. It was never persisted, so this only clears the + * in-memory list — there is nothing on a relay to take back. + */ + fun dismissDirectInvite(communityIdHex: String) { + _directInvites.update { invites -> + invites.filterNot { it.communityId.equals(communityIdHex, ignoreCase = true) } + } + } /** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */ suspend fun join(preview: InvitePreview): CommunityState { @@ -249,28 +265,24 @@ class ConcordManager(private val nostr: Nostr) { val store = store ?: throw IllegalStateException("Concord storage is not ready") val membership = preview.invite.toMembership(preview.relays) - val updated = _memberships.value.filterNot { it.communityId == membership.communityId } + membership + val updated = + _memberships.value.filterNot { it.communityId == membership.communityId } + membership store.saveMemberships(updated) _memberships.value = updated refreshPlanes() subscribeCommunity(membership) - // CORD-02 §5: a Join is each member's own word, published to the Guestbook. There is no - // Guestbook fold in v1 — the Control plane is what drives the UI. if (!preview.alreadyJoined) publishJoin(membership, preview.invite) _directInvites.update { invites -> invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) } } + return _communities.value.firstOrNull { it.membership.communityId == membership.communityId } ?: CommunityState(membership, null, emptyList()) } - // ----------------------------------------------------------------------------------------- - // Reading - // ----------------------------------------------------------------------------------------- - /** * A Channel's messages, oldest first. Reads the local cache; the live subscription is what * keeps it current. @@ -290,10 +302,6 @@ class ConcordManager(private val nostr: Nostr) { publishUnread(channelIdHex, 0) } - // ----------------------------------------------------------------------------------------- - // Writing - // ----------------------------------------------------------------------------------------- - /** * 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 { 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 { it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true) } ?: 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( author = author, @@ -334,8 +345,11 @@ class ConcordManager(private val nostr: Nostr) { .mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() } if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to") - client.sendEvent(event = wrap, target = SendEventTarget.to(relays), ackPolicy = AckPolicy.none()) - .failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") } + client.sendEvent( + event = wrap, + target = SendEventTarget.to(relays), + ackPolicy = AckPolicy.none() + ).failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") } return rumor.toConcordMessage() ?: throw IllegalStateException("Concord: could not read back the message just sent") @@ -365,10 +379,6 @@ class ConcordManager(private val nostr: Nostr) { publishUnread(channelIdHex, count) } - // ----------------------------------------------------------------------------------------- - // Planes - // ----------------------------------------------------------------------------------------- - /** * 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 } if (communityId != null && root != null) { - // Control is write-restricted: we hold the read key plus the writers' pubkey, which - // is all reading takes (CORD-01, CORD-02 §5). A bundle with no `control_pk` is a - // legacy, pre-split Community, whose plane was addressed by the `concord/control` - // derivation itself; v1 does not read those, so such a Community shows no metadata. - // CORD-02 §5 requires that legacy reading and the first base rotation upgrades it. membership.controlPk?.let { controlPkHex -> if (controlPkHex.hex32() != null) { - val key = controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex) + val key = + controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex) next[key.streamPublicKeyHex] = Plane( communityIdHex = membership.communityId, role = PlaneRole.Control, @@ -420,8 +426,6 @@ class ConcordManager(private val nostr: Nostr) { val stored = granted[channelIdHex] val channelId = channelIdHex.hex32() - // A Public Channel's key *is* the community_root, so one we were never granted is - // still derivable; a Private one is not (CORD-03 §1). val secret = stored?.key?.hex32() ?: if (meta?.isPrivate != true) root else null val epoch = stored?.epoch ?: membership.rootEpoch @@ -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. */ private suspend fun refreshControl(communityIdHex: String) { val store = store ?: return - val editions = store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) } + val editions = + store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) } folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex)) if (communityIdHex in refreshPlanes()) { - _memberships.value.firstOrNull { it.communityId == communityIdHex }?.let { subscribeCommunity(it) } + _memberships.value.firstOrNull { it.communityId == communityIdHex } + ?.let { subscribeCommunity(it) } } } @@ -490,12 +496,12 @@ class ConcordManager(private val nostr: Nostr) { val id = subscriptionId(membership.communityId) client.unsubscribe(id) - val relays = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }.distinct() + val relays = membership.relays + .mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() } + .distinct() + if (relays.isEmpty()) return - // Always the Community's own relays, never the app's defaults: Concord reverses NIP-59 - // (fixed author, ephemeral `p`), so a relay enforcing the optional `p`-tag guard drops - // these wraps, and the bootstrap set is tuned for NIP-17. relays.forEach { relay -> client.addRelay(relay) client.connectRelay(relay) @@ -505,7 +511,10 @@ class ConcordManager(private val nostr: Nostr) { .filter { it.communityIdHex == membership.communityId } .mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() } .distinct() - .map { Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it)) } + .map { + Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it)) + } + if (filters.isEmpty()) return client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id) @@ -516,14 +525,15 @@ class ConcordManager(private val nostr: Nostr) { val client = nostr.client ?: return val communityId = membership.communityId.hex32() ?: return val root = membership.communityRoot.hex32() ?: return - val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + val author = + nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch) - // CORD-05 §1: an accepting joiner echoes the invite's creator and label, which is what - // makes per-link usage counters possible at all. - val extraTags = invite?.creatorNpub?.let { creator -> - listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty()))) - }.orEmpty() + + val extraTags = invite?.creatorNpub + ?.let { creator -> + listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty()))) + }.orEmpty() val rumor = plane.rumor( author = author, @@ -534,11 +544,18 @@ class ConcordManager(private val nostr: Nostr) { ) val wrap = plane.wrap(rumor, nostr.signer) - val targets = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() } - client.sendEvent(event = wrap, target = SendEventTarget.to(targets), ackPolicy = AckPolicy.none()) + val targets = + membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() } + + client.sendEvent( + event = wrap, + target = SendEventTarget.to(targets), + ackPolicy = AckPolicy.none() + ) } - private fun subscriptionId(communityIdHex: String): String = "$SUBSCRIPTION_PREFIX$communityIdHex" + private fun subscriptionId(communityIdHex: String): String = + "$SUBSCRIPTION_PREFIX$communityIdHex" private fun preview( invite: CommunityInvite, @@ -546,14 +563,21 @@ class ConcordManager(private val nostr: Nostr) { fallbackRelays: List, ): InvitePreview { val relays = invite.relaySet(fallbackRelays) - val problems = invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList() + val problems = + invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList() + return InvitePreview( link = link, invite = invite, relays = relays, problems = problems, expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()), - alreadyJoined = _memberships.value.any { it.communityId.equals(invite.communityId, ignoreCase = true) }, + alreadyJoined = _memberships.value.any { + it.communityId.equals( + invite.communityId, + ignoreCase = true + ) + }, ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt index 33a62da..89d52d4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt @@ -6,35 +6,11 @@ import kotlinx.serialization.json.Json import rust.nostr.sdk.UnsignedEvent import kotlin.time.Instant -/** - * Concord's data model: what we persist about a Community, what an invite carries, and what the - * Control fold projects. - * - * Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon]) - * keep the spec's snake_case field names because those keys are normative. Types that are ours - * alone ([Membership], [StoredChannelKey]) keep Kotlin naming. - * - * Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to - * round-trip fields they do not understand. v1 only ever *reads* these documents, so the models - * below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing - * them back must preserve what it did not parse. - */ internal val concordJson = Json { ignoreUnknownKeys = true encodeDefaults = true } -// --------------------------------------------------------------------------------------------- -// Persisted -// --------------------------------------------------------------------------------------------- - -/** - * A Community this device has joined, as stored locally. Holding these keys *is* membership - * (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store. - * - * There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key - * cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise. - */ @Serializable data class Membership( /** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */ @@ -59,10 +35,6 @@ data class Membership( val channels: List = emptyList(), ) -/** - * A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry - * it here, which is why [private] can only be a hint — the Control fold is the authority. - */ @Serializable data class StoredChannelKey( val id: String, @@ -72,18 +44,6 @@ data class StoredChannelKey( val private: Boolean = false, ) -// --------------------------------------------------------------------------------------------- -// Invite bundle (CORD-05 §1) -// --------------------------------------------------------------------------------------------- - -/** - * The `CommunityInvite` bundle: the same document whether it arrives inside a public link's - * encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6). - * - * The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real - * Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will - * never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it. - */ @Serializable data class CommunityInvite( @SerialName("community_id") val communityId: String = "", @@ -111,10 +71,6 @@ data class InviteChannel( val name: String? = null, ) -/** - * A pointer to an encrypted blob — icon, banner (CORD-02 §6). The media server holds ciphertext - * only; a member fetches, decrypts and verifies [hash]. v1 never fetches these. - */ @Serializable data class ConcordIcon( val url: String? = null, @@ -123,10 +79,6 @@ data class ConcordIcon( val hash: String? = null, ) -// --------------------------------------------------------------------------------------------- -// Control fold -// --------------------------------------------------------------------------------------------- - /** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */ @Serializable data class CommunityMeta( @@ -149,13 +101,6 @@ data class ChannelMeta( val deleted: Boolean = false, ) -/** - * One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B). - * - * The tags are the edition machinery — `vsk` names the entity type, `eid` its stable coordinate, - * `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as - * a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization. - */ data class ControlEdition( val vsk: Int, val eidHex: String, @@ -173,10 +118,6 @@ data class ControlFold( val channels: Map, ) -// --------------------------------------------------------------------------------------------- -// Read surface -// --------------------------------------------------------------------------------------------- - /** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */ data class ConcordChannel( val idHex: String, @@ -225,18 +166,8 @@ data class InvitePreview( val joinable: Boolean get() = problems.isEmpty() && !expired } -// --------------------------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------------------------- - -/** - * CORD-05 §1: the bundle's `expires_at` is unix **milliseconds**, the Invite List's is unix - * **seconds**, and a NIP-40 `expiration` tag is **seconds**. Three spellings of one instant, so - * every conversion lives here. Returns null when the bundle carries no expiry at all. - */ fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 } -/** CORD-02 §4: true time is `created_at * 1000 + ms`; an absent or out-of-range `ms` reads as 0. */ internal fun UnsignedEvent.concordTimestampMs(): Long { val ms = tags().toVec() .firstOrNull { it.kind() == ConcordTag.MS } @@ -246,7 +177,6 @@ internal fun UnsignedEvent.concordTimestampMs(): Long { return createdAt().asSecs().toLong() * 1000 + (ms ?: 0) } -/** Projects a Chat-plane rumor into a [ConcordMessage], or null when it is not a message. */ internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? { if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null return ConcordMessage( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt index 85d0f02..8c5070c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt @@ -15,85 +15,23 @@ import rust.nostr.sdk.nip44Decrypt import rust.nostr.sdk.nip44Encrypt import kotlin.time.Instant -/** - * The CORD-01 wire stack: `wrap → seal → rumor`. - * - * A stream is a shared key and a stream of gift wraps signed with it. Everything a plane sends - * is three nested layers: - * - * ``` - * wrap kind 1059, signed by the stream key, one ephemeral `p` tag - * └ nip44(conv_key) of - * seal kind 20013 (encrypted) or 20014 (plaintext), signed by the real author - * └ nip44(conv_key) of | byte-verbatim - * rumor unsigned, its authority is the seal's signature around it - * ``` - * - * Both encrypted layers use the *same* conversation key — that is double encryption under one - * key, not two, and it is what makes the wrap readable by anyone holding the plane key. - * - * Concord reverses NIP-59: the author is fixed (the stream key) and the `p` tag is ephemeral, - * which is why the app's normal NIP-59 path in `MessageManager.extractRumor` cannot read these - * and why routing happens by author before that code is reached. - */ - -/** - * Which of CORD-01's two seal forms a plane uses; CORD-02 §5 makes this a fixed property of the - * plane, "never a per-message choice". Because the plane owns it, [wrap] cannot pick the wrong - * one and [unwrap] can reject a seal of the other form instead of quietly accommodating it. - */ enum class SealForm(private val kindValue: Int) { - /** - * Chat, Guestbook, rekey. The rumor is NIP-44-encrypted inside the already-encrypted wrap, - * so no relay — honest or malicious — can retain and display the rumor as a public event. - */ Encrypted(ConcordKind.SEAL), - - /** - * Control plane only. The seal's content is the rumor's serialized JSON **byte-verbatim**, - * because a signature over ciphertext is bound to the key that encrypted it and would break - * if re-wrapped under another key across an epoch change. - */ Plaintext(ConcordKind.PLAINTEXT_SEAL); val kind: UShort get() = kindValue.toUShort() } -/** - * CORD-03 §3: what a Chat-plane rumor must commit so a member cannot re-wrap another's message - * into a different Channel or replay it across an epoch. - * - * The tags live *inside* the author-signed rumor, so the author's signature covers them; the - * reader checks them strict-equal against the coordinate whose key opened the wrap. - */ data class ChatBinding(val channelIdHex: String, val epoch: ULong) -/** - * One plane's keys and, for a Chat plane, the coordinate its rumors are bound to. - * - * [read] decrypts: it is the conversation key for the wrap and for an encrypted seal. [stream] - * is the address that signs wraps. They are the same key on a normal stream, and differ only on - * a write-restricted one (CORD-01, used by the Control Plane in CORD-02 §5) — where a reader - * holds the read key plus the writers' *pubkey*, enough to verify a wrap but not to mint one. - * - * Deliberately not a `data class`: there is no value equality worth having, and the default - * `toString` keeps key material out of logs. - */ class PlaneKey( val read: GroupKey, val stream: GroupKey?, - /** The x-only hex of the key every wrap on this plane must be signed by. */ val streamPublicKeyHex: String, - /** CORD-02 §5: the one seal form this plane may use, on both the write and the read side. */ val form: SealForm, val chat: ChatBinding?, ) -/** - * A Channel's Chat plane (CORD-03 §1). Public channels pass `community_root` as the secret, - * Private ones their own independent `channel_key`; the `channel_id` in the derivation is what - * gives each Channel a distinct address either way. - */ fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey { val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch) return PlaneKey( @@ -105,7 +43,6 @@ fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong ) } -/** The community-wide Guestbook plane (CORD-02 §5), where joins, leaves and kicks are recorded. */ fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey { val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch) return PlaneKey( @@ -117,12 +54,6 @@ fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: U ) } -/** - * The Control plane's **read** key (CORD-02 §5). Its wraps are signed by the staff-held - * `control-signer` key instead, so this plane is read-only and [streamPublicKeyHex] is - * whatever the invite claimed — nothing in an invite can prove it, so build nothing - * security-relevant on it beyond the subscription address. - */ fun controlPlaneKey( communityRoot: ByteArray, communityId: ByteArray, @@ -139,17 +70,6 @@ fun controlPlaneKey( ) } -/** - * Builds an unsigned rumor for this plane (CORD-01). - * - * The `channel`/`epoch` binding tags are stamped from the plane itself, and therefore from the - * very key that will encrypt the wrap, so a rumor can never be built whose coordinate does not - * match the key it travels under. `ms` rides every rumor (CORD-02 A.5) because `created_at` is - * never tweaked — true time is `created_at * 1000 + ms`. - * - * A rumor is never signed and never a standalone artifact: its authority is the seal's - * signature around it. - */ fun PlaneKey.rumor( author: PublicKey, kind: UShort, @@ -172,47 +92,28 @@ fun PlaneKey.rumor( .ensureId() } -/** - * Wraps [rumor] into a publishable stream event. - * - * [signer] signs the *seal*, so it must be the real author's signer — the user's own key, which - * also means a NIP-46 bunker works here. The wrap is signed by the plane's stream key instead, - * and the seal form comes from the plane itself (CORD-02 §5). - * - * Both the seal and the wrap take the rumor's `created_at`, never a fresh one, so the three - * layers agree and pagination by wrap timestamp lines up with message ordering. - */ suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event { val stream = stream ?: error("Concord: this plane is read-only and cannot wrap") val createdAt = rumor.createdAt() val rumorJson = rumor.asJson() + val content = if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson + val seal = try { - EventBuilder( - Kind(form.kind), - // CORD-01: byte-verbatim for a plaintext seal, so a re-wrap can carry the exact - // signed bytes forward instead of re-serializing them. - if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson, - ) - .customCreatedAt(createdAt) - .finalizeAsync(signer) + EventBuilder(Kind(form.kind), content).customCreatedAt(createdAt).finalizeAsync(signer) } catch (e: CancellationException) { throw e } catch (e: Exception) { throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e) } - // The receiver drops a rumor whose author differs from its seal's, so publishing a mismatch - // would produce a message nobody can read. Fail here instead, where the cause is visible. check(seal.author() == rumor.author()) { - "Concord: seal author ${seal.author().toHex()} does not match rumor author ${rumor.author().toHex()}" + "Concord: seal author ${seal.author().toHex()} does not match rumor author ${ + rumor.author().toHex() + }" } return try { - // The ephemeral `p` is discarded: it only breaks linkage between a plane's wraps. Only - // its pubkey is kept, and `Tag.publicKey` has already serialized it, so destroying the - // keypair right away is safe. v1 has no giftwrap deletion, which is the one thing - // CORD-01 §Deletions would want the secret for. val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) } Keys(stream.secretKey).use { keys -> EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson())) @@ -227,19 +128,8 @@ suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event } } -/** - * Unwraps a stream event and enforces every check a reader must make, returning null when any - * of them fails. - * - * Null means **drop**, never retry: a wrong key, a forged or unverifiable signature, an - * impersonation attempt, a decompression/serialization failure, or a `channel`/`epoch` - * mismatch (CORD-03 §3). The checks are all here so a caller cannot skip one, and nothing is - * rendered before they pass. - */ fun PlaneKey.unwrap(event: Event): UnsignedEvent? { if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null - // The wrap is signed by the stream key. Verifying this is what makes CORD-01's - // write-restricted split real: a read-key holder can verify a wrap but cannot mint one. if (event.author().toHex() != streamPublicKeyHex) return null if (!event.verify()) return null @@ -248,8 +138,6 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? { }.getOrNull() ?: return null if (!seal.verify()) return null - // CORD-02 §5 makes the seal form a fixed property of the plane, so a seal of the other form - // is a discipline violation, not a variant to accommodate: only the matching pair is accepted. val sealKind = seal.kind().asU16() val rumorJson = when { sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching { @@ -260,19 +148,14 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? { else -> return null } - val rumor = runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null + val rumor = + runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null // NIP-59's impersonation check: the seal proves who wrote the rumor inside it. if (rumor.author() != seal.author()) return null if (!bindsToThisPlane(rumor)) return null return rumor } -/** - * CORD-03 §3, strict-equal and fail-closed: on a Chat plane both tags must be present and match - * this plane's coordinate, so neither a re-wrap into another Channel nor a cross-epoch replay - * survives. The community-wide Guestbook and Control planes split no sub-context, so the spec - * binds nothing there and this is a no-op. - */ private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean { val expected = chat ?: return true val tags = rumor.tags().toVec() @@ -281,20 +164,9 @@ private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean { return channel == expected.channelIdHex && epoch == expected.epoch.toString() } -/** - * CORD-02 A.5: true time is `created_at * 1000 + ms`, and a reader drops a rumor whose `ms` - * falls outside `0..999`. `mod` rather than `%` so a pre-epoch timestamp cannot produce a - * negative value. - */ private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt() -/** - * NIP-44's plaintext cap, enforced by the publisher (CORD-01 §Encoding). - * - * Libraries are lenient and a lenient publisher mints events a strict reader cannot decrypt, so - * this fails loudly at build time instead of producing an undecryptable message. - */ -private const val MAX_PLAINTEXT_BYTES = 65_535 +private fun concordKind(kind: Int): UShort = kind.toUShort() private fun nip44Seal(key: GroupKey, plaintext: String): String { val size = plaintext.encodeToByteArray().size @@ -304,5 +176,4 @@ private fun nip44Seal(key: GroupKey, plaintext: String): String { return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2) } -/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */ -private fun concordKind(kind: Int): UShort = kind.toUShort() +private const val MAX_PLAINTEXT_BYTES = 65_535 diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt index c265b02..09a042b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt @@ -1,8 +1,6 @@ package su.reya.coop.concord import kotlinx.coroutines.CancellationException -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import rust.nostr.sdk.EventBuilder import rust.nostr.sdk.Filter import rust.nostr.sdk.Keys @@ -13,19 +11,7 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.AppStorage import su.reya.coop.nostr.Nostr -/** - * Concord's local persistence, in the two places the rest of the app already keeps things. - * - * **Secrets → [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2), - * so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go - * through plaintext storage, and they never touch LMDB. - * - * **Community state → LMDB, as index events.** Decrypted plane rumors are cached the same way - * [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the - * Control fold has something to fold before the network answers. - */ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) { - suspend fun loadMemberships(): List { val raw = try { storage.getSecret(MEMBERSHIPS_KEY) @@ -54,27 +40,28 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) { } } - /** - * Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the - * community id for Control and Guestbook. - * - * The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable* - * kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every - * message would replace the one before it and history would vanish silently. The wrap id is - * that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it — - * and it doubles as the dedupe key when a wrap arrives from several relays. - */ + suspend fun clearMemberships() { + try { + storage.clear(MEMBERSHIPS_KEY) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: could not clear memberships: ${e.message}") + } + } + suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) { try { - val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson()) - .tags( - listOf( - Tag.identifier(wrapIdHex), - Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)), - Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())), + val event = + EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson()) + .tags( + listOf( + Tag.identifier(wrapIdHex), + Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)), + Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())), + ) ) - ) - .finalizeAsync(Keys.generate()) + .finalizeAsync(Keys.generate()) nostr.client?.database()?.saveEvent(event) } catch (e: CancellationException) { @@ -99,14 +86,16 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) { } return events - .mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() } + .mapNotNull { + runCatching { + UnsignedEvent.fromJson(it.content()).ensureId() + }.getOrNull() + } .sortedBy { it.createdAt().asSecs() } } private companion object { const val MEMBERSHIPS_KEY = "concord_memberships" - - /** Single-letter index tags; `r` is what `Filter.reference` queries. */ const val INDEX_SCOPE_TAG = "r" const val INDEX_KIND_TAG = "k" } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ConcordRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ConcordRepository.kt new file mode 100644 index 0000000..950ebd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ConcordRepository.kt @@ -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> = concord.communities + + /** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */ + val directInvites: StateFlow> = concord.directInvites + + /** True once memberships have been loaded, so an empty list means "joined nothing". */ + val isReady: StateFlow = concord.restored + + /** Bumped whenever a plane rumor lands, so an open Channel knows to re-read its history. */ + val revision: StateFlow = concord.revision + + /** Fetches and decrypts the bundle behind a public invite link. */ + suspend fun previewInvite(link: String): InvitePreview? = attempt { concord.previewInvite(link) } + + /** The same preview for a Direct Invite, which already arrived with its bundle in hand. */ + fun previewDirectInvite(invite: CommunityInvite): InvitePreview = + concord.previewDirectInvite(invite) + + /** Accepts an invite: persist the keys, connect, subscribe, announce the join. */ + suspend fun join(preview: InvitePreview): CommunityState? = attempt { concord.join(preview) } + + /** A Channel's history, oldest first, from the local cache the live subscription keeps current. */ + suspend fun channelMessages(channelIdHex: String): List = + attempt { concord.channelMessages(channelIdHex) }.orEmpty() + + suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? = + attempt { concord.sendChannelMessage(channelIdHex, content) } + + /** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */ + fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex) + + fun dismissDirectInvite(communityIdHex: String) = concord.dismissDirectInvite(communityIdHex) + + fun resetInternalState() { + scope.launch(defaultDispatcher) { attempt { concord.reset() } } + } + + /** Runs one Concord call off the main thread, reporting any failure instead of throwing. */ + private suspend fun attempt(block: suspend () -> T): T? = withContext(defaultDispatcher) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + showError("Error: ${e.message}") + null + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChannelScreenViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChannelScreenViewModel.kt new file mode 100644 index 0000000..c78abe6 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChannelScreenViewModel.kt @@ -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 = accountRepository.currentUserProfile + + val channel: StateFlow = concordRepository.communities + .map { communities -> + communities + .firstOrNull { it.membership.communityId == communityId } + ?.channels + ?.firstOrNull { it.idHex == channelId } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + var loading by mutableStateOf(true) + val messages = mutableStateListOf() + + private var reloadJob: Job? = null + + init { + reload() + + viewModelScope.launch { + concordRepository.revision.drop(1).collect { reload() } + } + } + + private fun reload() { + reloadJob?.cancel() + reloadJob = viewModelScope.launch { + val loaded = concordRepository.channelMessages(channelId) + 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) } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ConcordViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ConcordViewModel.kt new file mode 100644 index 0000000..a1d1725 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ConcordViewModel.kt @@ -0,0 +1,27 @@ +package su.reya.coop.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.StateFlow +import su.reya.coop.concord.CommunityInvite +import su.reya.coop.concord.CommunityState +import su.reya.coop.concord.InvitePreview +import su.reya.coop.repository.ConcordRepository + +class ConcordViewModel( + private val repository: ConcordRepository, +) : ViewModel(), ErrorHost by repository { + val communities: StateFlow> = repository.communities + val directInvites: StateFlow> = repository.directInvites + val isReady: StateFlow = repository.isReady + + fun previewDirectInvite(invite: CommunityInvite): InvitePreview = + repository.previewDirectInvite(invite) + + suspend fun previewInvite(link: String): InvitePreview? = repository.previewInvite(link) + + suspend fun join(preview: InvitePreview): CommunityState? = repository.join(preview) + + fun dismissDirectInvite(communityIdHex: String) = repository.dismissDirectInvite(communityIdHex) + + fun resetInternalState() = repository.resetInternalState() +}