add simple ui
This commit is contained in:
@@ -12,6 +12,24 @@ Implementation plan for adding **Concord** communities and channels to Coop.
|
||||
2. Reuse existing APIs and types from the nostr SDK; only create new code where the SDK has nothing.
|
||||
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<UnsignedEvent>`) |
|
||||
|
||||
### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/`
|
||||
**Delivered in M5.**
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `CommunitiesScreen.kt` | Joined communities list + FAB → Join |
|
||||
| `CommunityScreen.kt` | Channel list for one community |
|
||||
| `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join |
|
||||
| `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states |
|
||||
| `Community.kt` | Display helpers over the read model — `CommunityState.displayName()` / `.unreadTotal()`, `ConcordMessage.timeLabel()` / `.dayLabel()`. Deliberately *not* a `RoomUiState`-style mirror: a Community's name is already in the fold, so there is no async lookup to model |
|
||||
| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, flows forwarded straight from the manager, and one private `attempt` funnel that hops to `defaultDispatcher` and reports instead of throwing |
|
||||
| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel`. `previewInvite` / `join` stay **suspend** so the Join screen owns its spinner |
|
||||
| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel`, but re-reads on the manager's `revision` counter since a plane message is not pushed as an event |
|
||||
|
||||
### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/`
|
||||
|
||||
**Delivered in M5.** All five live in one `communities` package, matching the `screens/chat/` convention.
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `CommunitiesScreen.kt` | Joined communities list + Direct Invite cards + FAB → Join |
|
||||
| `CommunityScreen.kt` | Channel list for one community, split public / private |
|
||||
| `ChannelScreen.kt` | Channel history, Discord-style. **Not in the original §5.3 list** — it was implied by the §11 table but omitted from the file plan |
|
||||
| `JoinCommunityScreen.kt` | Paste link / scan QR → preview card → Join |
|
||||
| `CommunityComponents.kt` | `CommunityRow`, `ChannelRow`, `DirectInviteCard`, `CommunityEmptyState`, `BetaNotice`, `shortCommunityId` |
|
||||
|
||||
**Deviation:** there is no `CommunityScreenViewModel`. That screen is a pure lookup of one `CommunityState` by id inside the existing `communities` flow, so an entry-scoped view model would have been a file with no state in it.
|
||||
|
||||
### 5.4 Tests — **none are kept**
|
||||
|
||||
@@ -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=<urlencoded>` and let Coo
|
||||
|
||||
## 11. UI
|
||||
|
||||
Mirrors the DM screens structurally so it feels native to Coop.
|
||||
Mirrors the DM screens structurally so it feels native to Coop. **Delivered in M5** — see
|
||||
[M5](#m5--ui--done) for what actually shipped and what did not.
|
||||
|
||||
| Screen | Mirrors | Notes |
|
||||
|---|---|---|
|
||||
| `CommunitiesScreen` | `HomeScreen` list + FAB | Community avatar (`Avatar`), name, channel count. Empty state per `ContactListScreen` convention. |
|
||||
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock icon on private. `#general` comes from genesis. |
|
||||
| `ChannelScreen` | `ChatScreen` | Reuse `DateSeparator`, `ChatInput`, `Avatar`. Discord-style (author shown per message) rather than Coop's DM style. |
|
||||
| `CommunitiesScreen` | `HomeScreen` list + FAB | Community name and channel count, plus Direct Invite cards above the list. **No avatar** for a Community: its icon is an encrypted blob v1 never fetches, so the row shows the placeholder rather than a picture it does not have. Empty state per `ContactListScreen` convention. |
|
||||
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock on private, `No key` on one we cannot read. `#general` 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):
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="56"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M120,220 Q120,120 220,120 L740,120 Q840,120 840,220 L840,740 Q840,840 740,840 L220,840 Q120,840 120,740 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="72"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M400,320 L400,640 M560,320 L560,640 M320,400 L640,400 M320,560 L640,560" />
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M240,500 Q240,440 300,440 L660,440 Q720,440 720,500 L720,780 Q720,840 660,840 L300,840 Q240,840 240,780 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="64"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M340,500 L340,440 A140,140 0 0 1 620,440 L620,500" />
|
||||
</vector>
|
||||
@@ -39,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.screens.ContactListScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
@@ -54,9 +55,15 @@ import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.SettingsScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
import su.reya.coop.screens.chat.ChatScreen
|
||||
import su.reya.coop.screens.communities.ChannelScreen
|
||||
import su.reya.coop.screens.communities.CommunitiesScreen
|
||||
import su.reya.coop.screens.communities.CommunityScreen
|
||||
import su.reya.coop.screens.communities.JoinCommunityScreen
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
@@ -90,6 +97,7 @@ fun App(
|
||||
profileCache: ProfileCache,
|
||||
accountRepository: AccountRepository,
|
||||
chatRepository: ChatRepository,
|
||||
concordRepository: ConcordRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
connectivityMonitor: ConnectivityMonitor,
|
||||
) {
|
||||
@@ -109,6 +117,10 @@ fun App(
|
||||
settingsRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(
|
||||
concordRepository
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -120,6 +132,7 @@ fun App(
|
||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||
val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
@@ -170,6 +183,11 @@ fun App(
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
concordViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
profileCache.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
@@ -237,7 +255,7 @@ fun App(
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Home> {
|
||||
HomeScreen(accountViewModel, chatViewModel)
|
||||
HomeScreen(accountViewModel, chatViewModel, concordViewModel)
|
||||
}
|
||||
entry<Screen.RequestList> {
|
||||
RequestListScreen(chatViewModel)
|
||||
@@ -297,6 +315,36 @@ fun App(
|
||||
entry<Screen.Settings> {
|
||||
SettingsScreen(settingsViewModel)
|
||||
}
|
||||
entry<Screen.Communities> {
|
||||
CommunitiesScreen(concordViewModel)
|
||||
}
|
||||
entry<Screen.JoinCommunity> { key ->
|
||||
JoinCommunityScreen(concordViewModel, key.link)
|
||||
}
|
||||
entry<Screen.Community> { key ->
|
||||
CommunityScreen(key.communityId, concordViewModel)
|
||||
}
|
||||
entry<Screen.Channel> { key ->
|
||||
val factory = remember(key) {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ChannelScreenViewModel(
|
||||
key.communityId,
|
||||
key.channelId,
|
||||
accountRepository,
|
||||
concordRepository
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
ChannelScreen(
|
||||
viewModel<ChannelScreenViewModel>(
|
||||
key = "${key.communityId}:${key.channelId}",
|
||||
factory = factory
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.MainScope
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
@@ -52,6 +53,10 @@ class MainActivity : ComponentActivity() {
|
||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||
}
|
||||
|
||||
private val concordRepository by lazy {
|
||||
ConcordRepository(NostrManager.instance, scope)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
throwable.printStackTrace()
|
||||
@@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() {
|
||||
profileCache = profileCache,
|
||||
accountRepository = accountRepository,
|
||||
chatRepository = chatRepository,
|
||||
concordRepository = concordRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
connectivityMonitor = connectivityMonitor,
|
||||
)
|
||||
|
||||
@@ -61,4 +61,16 @@ sealed interface Screen : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen
|
||||
|
||||
@Serializable
|
||||
data object Communities : Screen
|
||||
|
||||
@Serializable
|
||||
data class Community(val communityId: String) : Screen
|
||||
|
||||
@Serializable
|
||||
data class Channel(val communityId: String, val channelId: String) : Screen
|
||||
|
||||
@Serializable
|
||||
data class JoinCommunity(val link: String? = null) : Screen
|
||||
}
|
||||
|
||||
@@ -102,17 +102,20 @@ import su.reya.coop.RoomKind
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.ago
|
||||
import su.reya.coop.concord.parseInviteLink
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.uiStateFlow
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
chatViewModel: ChatViewModel,
|
||||
concordViewModel: ConcordViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.current
|
||||
@@ -166,16 +169,22 @@ fun HomeScreen(
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
// A Concord invite is a link, not a npub, so it is routed to the Join screen instead of
|
||||
// being force-fed to PublicKey.parse.
|
||||
if (parseInviteLink(result) != null) {
|
||||
navigator.navigate(Screen.JoinCommunity(result))
|
||||
} else {
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
}
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
@@ -473,7 +482,8 @@ fun HomeScreen(
|
||||
BottomMenuList(
|
||||
onDismiss = dismissAndRun,
|
||||
accountViewModel = accountViewModel,
|
||||
chatViewModel = chatViewModel
|
||||
chatViewModel = chatViewModel,
|
||||
concordViewModel = concordViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -788,12 +798,14 @@ fun BottomMenuList(
|
||||
onDismiss: (suspend () -> Unit) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
concordViewModel: ConcordViewModel,
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||
"Communities (beta)" to { navigator.navigate(Screen.Communities) },
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Settings" to { navigator.navigate(Screen.Settings) }
|
||||
)
|
||||
@@ -824,6 +836,8 @@ fun BottomMenuList(
|
||||
accountViewModel.logout(onLogout = {
|
||||
accountViewModel.resetInternalState()
|
||||
chatViewModel.resetInternalState()
|
||||
// Concord's keys are identity-scoped, so they leave with the identity.
|
||||
concordViewModel.resetInternalState()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,8 +38,8 @@ fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onUpload: () -> Unit,
|
||||
onMicClick: () -> Unit
|
||||
onUpload: (() -> Unit)? = null,
|
||||
onMicClick: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
@@ -60,12 +60,16 @@ fun ChatInput(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Default
|
||||
),
|
||||
leadingIcon = {
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_add_circle),
|
||||
contentDescription = "Upload",
|
||||
)
|
||||
leadingIcon = if (onUpload == null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_add_circle),
|
||||
contentDescription = "Upload",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -75,9 +79,21 @@ fun ChatInput(
|
||||
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||
label = "send_mic_transition"
|
||||
) { isNotEmpty ->
|
||||
if (isNotEmpty) {
|
||||
// Nothing to send and nowhere to dictate to: the send button stays, greyed out.
|
||||
if (!isNotEmpty && onMicClick != null) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onMicClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_audio),
|
||||
contentDescription = "Speech to Text"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = isNotEmpty,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -89,16 +105,6 @@ fun ChatInput(
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledTonalIconButton(
|
||||
onClick = onMicClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_audio),
|
||||
contentDescription = "Speech to Text"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import su.reya.coop.dayLabel
|
||||
import su.reya.coop.sanitizeName
|
||||
import su.reya.coop.screens.chat.ChatInput
|
||||
import su.reya.coop.screens.chat.DateSeparator
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.timeLabel
|
||||
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
||||
|
||||
/**
|
||||
* One Channel's history, laid out Discord-style: every message carries its author, because a Channel
|
||||
* has many speakers rather than the two a DM has.
|
||||
*
|
||||
* The list is reversed, exactly as `ChatScreen` is, so the newest message sits at the bottom without
|
||||
* any scroll bookkeeping — the item order below is bottom-to-top, and the day separator is placed
|
||||
* after its group for that reason.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChannelScreen(viewModel: ChannelScreenViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val channel by viewModel.channel.collectAsStateWithLifecycle()
|
||||
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
val grouped by remember {
|
||||
derivedStateOf {
|
||||
viewModel.messages
|
||||
.groupBy { it.dayLabel() }
|
||||
.toList()
|
||||
.reversed()
|
||||
.map { (day, dayMessages) -> day to dayMessages.asReversed() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "#${channel?.name ?: "channel"}",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (channel?.hasKey == false) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "No key",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (viewModel.loading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else if (viewModel.messages.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
// Nothing to add for a Channel we hold no key for: the bar at the
|
||||
// bottom of this screen is already saying why it is empty.
|
||||
if (channel?.hasKey != false) {
|
||||
Text(
|
||||
text = "Say something to start it off.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
grouped.forEach { (day, dayMessages) ->
|
||||
items(dayMessages, key = { it.idHex }) { message ->
|
||||
ChannelMessageRow(
|
||||
message = message,
|
||||
isMine = message.author == currentUser?.publicKey?.toHex(),
|
||||
)
|
||||
}
|
||||
item(key = "day:$day") { DateSeparator(day) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Private Channel we hold no key for is listable but not writable, so the input
|
||||
// is replaced by the reason rather than left to fail on send.
|
||||
if (channel?.hasKey == false) {
|
||||
Text(
|
||||
text = "This channel was not part of the invite, so this device has no key " +
|
||||
"for it.",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
} else {
|
||||
// No attachments and no dictation in v1, so the input shows neither: a button
|
||||
// that does nothing would be worse than its absence.
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(text)
|
||||
text = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) {
|
||||
val (pubkey, profile) = rememberAuthor(message.author)
|
||||
val name = profile?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
||||
?: pubkey?.short()
|
||||
?: message.author.take(8)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Avatar(picture = profile?.picture, description = name, size = 36.dp)
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (isMine) "$name (you)" else name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(
|
||||
text = message.timeLabel(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile behind a message's author, fetched the ordinary Nostr way — see `Avatar`. */
|
||||
@Composable
|
||||
private fun rememberAuthor(authorHex: String): Pair<PublicKey?, Profile?> {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val pubkey = remember(authorHex) { runCatching { PublicKey.parse(authorHex) }.getOrNull() }
|
||||
val profile by remember(pubkey) {
|
||||
pubkey?.let { profileCache.getMetadata(it) } ?: flowOf<Profile?>(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
return pubkey to profile
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* Every Community this device has joined, plus any Direct Invite still waiting.
|
||||
*
|
||||
* There is no file upload, no message search and no per-Community settings page — v1 is the read
|
||||
* path and sending, and the screen says so rather than pretending otherwise.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunitiesScreen(concordViewModel: ConcordViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
||||
val directInvites by concordViewModel.directInvites.collectAsStateWithLifecycle()
|
||||
val isReady by concordViewModel.isReady.collectAsStateWithLifecycle()
|
||||
|
||||
val sorted = remember(communities) { communities.sortedBy { it.displayName().lowercase() } }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Communities",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above,
|
||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip { Text("Join a community") }
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { navigator.navigate(Screen.JoinCommunity()) },
|
||||
expanded = false,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "Join a community"
|
||||
)
|
||||
},
|
||||
text = { Text("Join") },
|
||||
)
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
when {
|
||||
!isReady && sorted.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
sorted.isEmpty() && directInvites.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
CommunityEmptyState(
|
||||
title = "No communities yet",
|
||||
subtitle = "Join one with an invite link.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
items(directInvites, key = { it.communityId }) { invite ->
|
||||
DirectInviteCard(
|
||||
invite = invite,
|
||||
onJoin = {
|
||||
scope.launch {
|
||||
// A Direct Invite already carries its bundle, so there is
|
||||
// nothing to fetch — only to validate and accept.
|
||||
val preview = concordViewModel.previewDirectInvite(invite)
|
||||
val joined = concordViewModel.join(preview)
|
||||
if (joined != null) {
|
||||
navigator.navigate(
|
||||
Screen.Community(joined.membership.communityId)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
concordViewModel.dismissDirectInvite(invite.communityId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
items(sorted, key = { it.membership.communityId }) { state ->
|
||||
CommunityRow(
|
||||
state = state,
|
||||
onClick = {
|
||||
navigator.navigate(
|
||||
Screen.Community(state.membership.communityId)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_communities
|
||||
import coop.composeapp.generated.resources.ic_lock
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.ConcordChannel
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.unreadTotal
|
||||
|
||||
/**
|
||||
* Rows and small blocks shared by the Communities screens, mirroring `HomeScreen`'s `ChatRoom` and
|
||||
* `ContactListItem`: `ListItem` for the top level, `SegmentedListItem` inside a group.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunityRow(state: CommunityState, onClick: () -> Unit) {
|
||||
val unread = state.unreadTotal()
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unread > 0) {
|
||||
Badge { Text(unread.toString()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Concord icons are encrypted blobs v1 never fetches, so this is always the placeholder.
|
||||
Avatar(picture = null, description = state.displayName())
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = state.displayName(),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = channelCountLabel(state.channels.size),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChannelRow(channel: ConcordChannel, index: Int, total: Int, onClick: () -> Unit) {
|
||||
val unread = channel.unreadCount
|
||||
|
||||
SegmentedListItem(
|
||||
onClick = onClick,
|
||||
shapes = ListItemDefaults.segmentedShapes(index = index, count = total),
|
||||
leadingContent = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unread > 0) {
|
||||
Badge { Text(unread.toString()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
||||
if (channel.private) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_lock),
|
||||
contentDescription = "Private",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
content = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = channel.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// A Private Channel with no key is listable but unreadable: the row says so rather
|
||||
// than opening an empty room and looking broken.
|
||||
if (!channel.hasKey) {
|
||||
Text(
|
||||
text = "No key",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Direct Invite (CORD-05 §6) waiting to be accepted. It arrived giftwrapped to this npub, so it is
|
||||
* shown on the Communities list rather than in any chat.
|
||||
*/
|
||||
@Composable
|
||||
fun DirectInviteCard(invite: CommunityInvite, onJoin: () -> Unit, onDismiss: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Invited to a community",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = invite.name ?: "Untitled community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
Text(
|
||||
text = shortCommunityId(invite.communityId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
Button(onClick = onJoin) { Text("Join") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's empty-state convention: two centred lines, with the feature's mark above them. */
|
||||
@Composable
|
||||
fun CommunityEmptyState(title: String, subtitle: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_communities),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp),
|
||||
tint = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says out loud what v1 does not do yet. Concord's authority is a signed roster every client folds
|
||||
* (CORD-04), and this build folds none of it — a stale claim of moderation would be worse than none.
|
||||
*/
|
||||
@Composable
|
||||
fun BetaNotice() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_lock),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = "Beta. Messages, channels and invites work. Roles, kicks and bans are not " +
|
||||
"enforced by this build yet — only by the other clients in the community.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelCountLabel(count: Int): String =
|
||||
if (count == 1) "1 channel" else "$count channels"
|
||||
|
||||
/** A Community's identity is a hash, so show enough of it to tell two apart. */
|
||||
internal fun shortCommunityId(communityId: String): String =
|
||||
if (communityId.length <= 16) communityId else communityId.take(16) + "..."
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* One Community's Channels, split public and private the way the protocol splits them: a Public
|
||||
* Channel's key derives from `community_root`, so anyone in the Community can read it, while a
|
||||
* Private one is only listable to those an invite or a rekey handed a key to (CORD-03 §1).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunityScreen(communityId: String, concordViewModel: ConcordViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
||||
val state = remember(communities, communityId) {
|
||||
communities.firstOrNull { it.membership.communityId == communityId }
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
// Reachable if the Community was dropped underneath this screen (a logout while it was open),
|
||||
// so it keeps a way back rather than dead-ending here.
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "This community is not on this device.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val (privateChannels, publicChannels) = remember(state.channels) {
|
||||
state.channels.partition { it.private }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.displayName(),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
item { BetaNotice() }
|
||||
|
||||
itemsIndexed(publicChannels) { index, channel ->
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
index = index,
|
||||
total = publicChannels.size,
|
||||
onClick = {
|
||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(privateChannels) { index, channel ->
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
index = index,
|
||||
total = privateChannels.size,
|
||||
onClick = {
|
||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.channels.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No channels yet. They arrive with the community's metadata.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* Paste or scan an invite link, see what joining would mean, then join.
|
||||
*
|
||||
* The preview is deliberately a separate step: a link is fetched and decrypted, but nothing is
|
||||
* subscribed and no presence is announced until the user accepts (CORD-05 §1), so backing out here
|
||||
* leaves no trace on any relay.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun JoinCommunityScreen(concordViewModel: ConcordViewModel, initialLink: String? = null) {
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var link by remember(initialLink) { mutableStateOf(initialLink.orEmpty()) }
|
||||
var preview by remember { mutableStateOf<InvitePreview?>(null) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { scanned ->
|
||||
link = scanned
|
||||
preview = null
|
||||
qrScanResult.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Join a community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = link,
|
||||
onValueChange = {
|
||||
link = it
|
||||
// Any edit invalidates what was fetched for the old text.
|
||||
preview = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Invite link") },
|
||||
placeholder = { Text("https://.../invite/...") },
|
||||
maxLines = 4,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
preview = concordViewModel.previewInvite(link)
|
||||
isBusy = false
|
||||
}
|
||||
},
|
||||
enabled = link.isNotBlank() && !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Look it up")
|
||||
}
|
||||
|
||||
preview?.let { shown ->
|
||||
InvitePreviewCard(
|
||||
preview = shown,
|
||||
isBusy = isBusy,
|
||||
onJoin = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
val joined = concordViewModel.join(shown)
|
||||
isBusy = false
|
||||
if (joined != null) {
|
||||
// Replace this screen with the Community it just joined.
|
||||
navigator.goBack()
|
||||
navigator.navigate(
|
||||
Screen.Community(joined.membership.communityId)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun InvitePreviewCard(preview: InvitePreview, isBusy: Boolean, onJoin: () -> Unit) {
|
||||
val invite = preview.invite
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = invite.name ?: "Untitled community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
Text(
|
||||
text = shortCommunityId(invite.communityId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = plural(invite.channels.size, "channel"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = plural(preview.relays.size, "relay"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
// Everything that would stop this invite from being accepted, in plain English.
|
||||
preview.problems.forEach { problem -> Refusal(problem) }
|
||||
if (preview.expired) Refusal("This invite has expired.")
|
||||
if (preview.alreadyJoined) {
|
||||
Text(
|
||||
text = "You are already in this community.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Button(
|
||||
onClick = onJoin,
|
||||
enabled = preview.joinable && !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isBusy) {
|
||||
LoadingIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text(if (preview.alreadyJoined) "Rejoin" else "Join")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Refusal(message: String) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun plural(count: Int, noun: String): String =
|
||||
if (count == 1) "1 $noun" else "$count ${noun}s"
|
||||
@@ -0,0 +1,46 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** Control metadata first, then the snapshot the invite carried, then a last-resort label. */
|
||||
fun CommunityState.displayName(): String {
|
||||
val name = meta?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
||||
?: membership.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
||||
return name ?: "Untitled community"
|
||||
}
|
||||
|
||||
/** Every Channel's badge added up, for the Community row. */
|
||||
fun CommunityState.unreadTotal(): Int = channels.sumOf { it.unreadCount }
|
||||
|
||||
/** `HH:mm`, local time. */
|
||||
fun ConcordMessage.timeLabel(): String {
|
||||
val time = createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val hour = time.hour.toString().padStart(2, '0')
|
||||
val minute = time.minute.toString().padStart(2, '0')
|
||||
return "$hour:$minute"
|
||||
}
|
||||
|
||||
/** `Today` / `Yesterday` / `DD/MM/YY`, matching [formatAsGroup] for DMs. */
|
||||
fun ConcordMessage.dayLabel(): String {
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val date = createdAt.toLocalDateTime(zone).date
|
||||
val today = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
return when (date) {
|
||||
today -> "Today"
|
||||
today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
|
||||
else -> {
|
||||
val day = date.day.toString().padStart(2, '0')
|
||||
val month = date.month.number.toString().padStart(2, '0')
|
||||
val year = date.year.toString().takeLast(2)
|
||||
"$day/$month/$year"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,36 +4,7 @@ import kotlinx.serialization.decodeFromString
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Folding the Control Plane (CORD-04 §1).
|
||||
*
|
||||
* The Control Plane carries the Community's authoritative state as **editions**: each is a
|
||||
* `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this
|
||||
* edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole
|
||||
* chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so.
|
||||
*
|
||||
* v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`).
|
||||
*
|
||||
* ## What v1 does not do
|
||||
*
|
||||
* **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the
|
||||
* owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could
|
||||
* publish a forged metadata or Channel edition and v1 would display it.
|
||||
*
|
||||
* That gap is bounded rather than open — only the owner and staff hold `control_root` (CORD-02 §2),
|
||||
* and the spec itself calls that secret "a spam gate, never authority" — but it is real. It is the
|
||||
* reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and
|
||||
* `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly
|
||||
* outranks the entity it edits.
|
||||
*/
|
||||
object ConcordControl {
|
||||
|
||||
/**
|
||||
* Parses one Control rumor into an edition, or null when it is not a foldable edition.
|
||||
*
|
||||
* A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1
|
||||
* does not implement dissolution, so treating it as an ordinary edition would misread it.
|
||||
*/
|
||||
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
|
||||
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
|
||||
|
||||
@@ -45,7 +16,6 @@ object ConcordControl {
|
||||
if (eidHex.hex32() == null) return null
|
||||
|
||||
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
|
||||
// CORD-04: versions climb from 1. A zero version has no place in the chain.
|
||||
if (version == 0uL) return null
|
||||
|
||||
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
|
||||
@@ -63,12 +33,6 @@ object ConcordControl {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the editions into the metadata and Channel list v1 renders.
|
||||
*
|
||||
* Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a
|
||||
* broken chain on one Channel never takes the Community's metadata down with it.
|
||||
*/
|
||||
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
|
||||
var meta: CommunityMeta? = null
|
||||
val channels = mutableMapOf<String, ChannelMeta>()
|
||||
@@ -90,16 +54,6 @@ object ConcordControl {
|
||||
return ControlFold(meta = meta, channels = channels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the highest version whose `ep` chain reaches all the way back to version 1.
|
||||
*
|
||||
* "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down
|
||||
* from the top and take the first version we can actually verify. A missing predecessor or a
|
||||
* broken link disqualifies that version, not the entity.
|
||||
*
|
||||
* Ties on the same version break on the **lower rumor id**, never on `created_at`, so two
|
||||
* clients folding the same two candidates always agree.
|
||||
*/
|
||||
private fun winner(group: List<ControlEdition>): ControlEdition? {
|
||||
val byVersion = group
|
||||
.groupBy { it.version }
|
||||
|
||||
@@ -8,29 +8,6 @@ import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.SecretKey
|
||||
|
||||
/**
|
||||
* Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A).
|
||||
*
|
||||
* Everything here is frozen by the spec, and a single wrong byte breaks interop silently
|
||||
* rather than loudly — so each function quotes the CORD section that governs it, and 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" }
|
||||
|
||||
@@ -5,23 +5,6 @@ import rust.nostr.sdk.Nip19Coordinate
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.nip44Decrypt
|
||||
|
||||
/**
|
||||
* CORD-05: redeeming an invite.
|
||||
*
|
||||
* Two ways to be handed the keys, one bundle:
|
||||
*
|
||||
* - **Public link** — `$BASE/invite/<naddr>#<fragment>`. The naddr names where the encrypted
|
||||
* bundle sits on relays; the fragment carries an off-network unlock token and never reaches a
|
||||
* server. The bundle is fetched, then decrypted with a key derived from the token.
|
||||
* - **Direct Invite** — the same bundle, giftwrapped straight to an npub. Nothing to fetch. That
|
||||
* path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`.
|
||||
*
|
||||
* Minting is out of v1's scope, so this file only decodes.
|
||||
*
|
||||
* A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the
|
||||
* strength of what the bundle claims (CORD-05 §1).
|
||||
*/
|
||||
|
||||
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
|
||||
internal object ConcordRelayDictionary {
|
||||
val STOCK = listOf(
|
||||
@@ -67,13 +50,6 @@ private const val MAX_COMMUNITY_RELAYS = 5
|
||||
/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
|
||||
private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
|
||||
|
||||
/**
|
||||
* Decodes a link into its locator and fragment, or null when it is not a Concord invite.
|
||||
*
|
||||
* The base domain is deliberately ignored — CORD-05 §2 makes the base interchangeable and says any
|
||||
* client recognizing an invite must respect the naddr and fragment verbatim — so only the last path
|
||||
* segment and the `#` fragment are read.
|
||||
*/
|
||||
fun parseInviteLink(text: String): ParsedInvite? {
|
||||
val trimmed = text.trim()
|
||||
val hash = trimmed.indexOf('#')
|
||||
@@ -93,16 +69,6 @@ fun parseInviteLink(text: String): ParsedInvite? {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-05 §3's fragment layout:
|
||||
*
|
||||
* ```
|
||||
* [version=4][flags][relays?][token:16]
|
||||
* ```
|
||||
*
|
||||
* With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries,
|
||||
* each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL.
|
||||
*/
|
||||
fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
|
||||
// Two header bytes plus the token at minimum.
|
||||
@@ -112,8 +78,7 @@ fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||
if (reader.byte() != FRAGMENT_VERSION) return null
|
||||
|
||||
val flags = reader.byte() ?: return null
|
||||
// The stock flag selects the whole dictionary, so nothing extra is carried and the cap on
|
||||
// explicit entries does not apply to it.
|
||||
|
||||
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
|
||||
ConcordRelayDictionary.STOCK
|
||||
} else {
|
||||
@@ -142,41 +107,24 @@ fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one
|
||||
* thing — decrypting the bundle — and nothing else.
|
||||
*
|
||||
* Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's
|
||||
* `nip44_encrypt(bundle_key, …)` is the same self-ECDH conversation key every other Concord
|
||||
* `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key
|
||||
* is what lets the existing SDK do the encryption instead of hand-rolling NIP-44.
|
||||
*/
|
||||
fun inviteBundleKey(tokenHex: String): GroupKey? =
|
||||
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
|
||||
|
||||
/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */
|
||||
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
|
||||
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
|
||||
?: return null
|
||||
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
|
||||
}
|
||||
|
||||
/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */
|
||||
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
|
||||
|
||||
/**
|
||||
* CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so
|
||||
* the preview can explain itself rather than silently refusing.
|
||||
*
|
||||
* The first one is the load-bearing one: `community_id == sha256("concord/community" ‖ owner ‖
|
||||
* owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community.
|
||||
*/
|
||||
fun CommunityInvite.problems(): List<String> {
|
||||
val problems = mutableListOf<String>()
|
||||
|
||||
val ownerBytes = owner.hex32()
|
||||
val saltBytes = ownerSalt.hex32()
|
||||
val rootBytes = communityRoot.hex32()
|
||||
|
||||
if (ownerBytes == null) problems += "The invite's owner key is malformed"
|
||||
if (saltBytes == null) problems += "The invite's owner salt is malformed"
|
||||
if (rootBytes == null) problems += "The invite's community key is malformed"
|
||||
@@ -186,12 +134,14 @@ fun CommunityInvite.problems(): List<String> {
|
||||
problems += "The invite does not prove its community id"
|
||||
}
|
||||
}
|
||||
|
||||
if (communityId.hex32() == null) problems += "The invite's community id is malformed"
|
||||
if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
|
||||
|
||||
if (channels.size > MAX_INVITE_CHANNELS) {
|
||||
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
|
||||
}
|
||||
|
||||
channels.forEachIndexed { index, channel ->
|
||||
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
|
||||
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
|
||||
@@ -200,20 +150,13 @@ fun CommunityInvite.problems(): List<String> {
|
||||
return problems
|
||||
}
|
||||
|
||||
/** CORD-05 §1: an expired bundle still previews, but joining refuses. */
|
||||
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
|
||||
|
||||
/**
|
||||
* The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays,
|
||||
* used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the
|
||||
* bundle's copy is the join-time snapshot of the real set (CORD-02 §6).
|
||||
*/
|
||||
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
|
||||
val source = relays.ifEmpty { fallback }
|
||||
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
|
||||
}
|
||||
|
||||
/** Turns a validated bundle into the membership we persist. */
|
||||
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
||||
communityId = communityId.lowercase(),
|
||||
owner = owner.lowercase(),
|
||||
@@ -230,14 +173,11 @@ fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
||||
key = channel.key.lowercase(),
|
||||
epoch = channel.epoch,
|
||||
name = channel.name,
|
||||
// A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the
|
||||
// only reading the bundle supports. The Control fold overrides this for display.
|
||||
private = !channel.key.equals(communityRoot, ignoreCase = true),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/** Reads the fragment's length-prefixed fields, refusing to run past the end. */
|
||||
private class FragmentReader(private val bytes: ByteArray) {
|
||||
var index = 0
|
||||
private set
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
/**
|
||||
* Frozen constants from the Concord specification.
|
||||
*
|
||||
* Concord is defined by the CORD documents (github.com/concord-protocol/concord); the
|
||||
* numbers, labels and permission bits below are normative and must match byte for byte
|
||||
* or nothing interoperates. They are collected in this one file so that a spec revision
|
||||
* is a one-file change and so that no literal kind number ever appears at a call site.
|
||||
*
|
||||
* References are to the CORD section that defines each value. See `PLAN.md` Appendix A
|
||||
* for the same tables with prose.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event kinds, either the outer wrap or the inner rumor it carries (CORD-01, CORD-02 §5,
|
||||
* CORD-02 Appendix B).
|
||||
*/
|
||||
object ConcordKind {
|
||||
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
|
||||
const val WRAP = 1059
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String, Plane> = emptyMap()
|
||||
|
||||
@@ -81,6 +54,13 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = _directInvites.asStateFlow()
|
||||
|
||||
/**
|
||||
* True once [restore] has run. An empty [memberships] means the same thing before and after it,
|
||||
* so a screen needs this to tell "joined nothing" from "not loaded yet".
|
||||
*/
|
||||
private val _restored = MutableStateFlow(false)
|
||||
val restored: StateFlow<Boolean> = _restored.asStateFlow()
|
||||
|
||||
private var store: ConcordStore? = null
|
||||
|
||||
/**
|
||||
@@ -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<CommunityInvite>(rumor.content()) }.getOrNull()
|
||||
val invite =
|
||||
runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
|
||||
if (invite == null) {
|
||||
println("Concord: a direct invite arrived but its bundle could not be read")
|
||||
return true
|
||||
@@ -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<String>,
|
||||
): InvitePreview {
|
||||
val relays = invite.relaySet(fallbackRelays)
|
||||
val problems = invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
||||
val problems =
|
||||
invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
||||
|
||||
return InvitePreview(
|
||||
link = link,
|
||||
invite = invite,
|
||||
relays = relays,
|
||||
problems = problems,
|
||||
expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
|
||||
alreadyJoined = _memberships.value.any { it.communityId.equals(invite.communityId, ignoreCase = true) },
|
||||
alreadyJoined = _memberships.value.any {
|
||||
it.communityId.equals(
|
||||
invite.communityId,
|
||||
ignoreCase = true
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,35 +6,11 @@ import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Concord's data model: what we persist about a Community, what an invite carries, and what the
|
||||
* Control fold projects.
|
||||
*
|
||||
* Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon])
|
||||
* keep the spec's snake_case field names because those keys are normative. Types that are ours
|
||||
* alone ([Membership], [StoredChannelKey]) keep Kotlin naming.
|
||||
*
|
||||
* Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to
|
||||
* round-trip fields they do not understand. v1 only ever *reads* these documents, so the models
|
||||
* below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing
|
||||
* them back must preserve what it did not parse.
|
||||
*/
|
||||
internal val concordJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Persisted
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A Community this device has joined, as stored locally. Holding these keys *is* membership
|
||||
* (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store.
|
||||
*
|
||||
* There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key
|
||||
* cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise.
|
||||
*/
|
||||
@Serializable
|
||||
data class Membership(
|
||||
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
|
||||
@@ -59,10 +35,6 @@ data class Membership(
|
||||
val channels: List<StoredChannelKey> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry
|
||||
* it here, which is why [private] can only be a hint — the Control fold is the authority.
|
||||
*/
|
||||
@Serializable
|
||||
data class StoredChannelKey(
|
||||
val id: String,
|
||||
@@ -72,18 +44,6 @@ data class StoredChannelKey(
|
||||
val private: Boolean = false,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Invite bundle (CORD-05 §1)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The `CommunityInvite` bundle: the same document whether it arrives inside a public link's
|
||||
* encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6).
|
||||
*
|
||||
* The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real
|
||||
* Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will
|
||||
* never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it.
|
||||
*/
|
||||
@Serializable
|
||||
data class CommunityInvite(
|
||||
@SerialName("community_id") val communityId: String = "",
|
||||
@@ -111,10 +71,6 @@ data class InviteChannel(
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pointer to an encrypted blob — icon, banner (CORD-02 §6). The media server holds ciphertext
|
||||
* only; a member fetches, decrypts and verifies [hash]. v1 never fetches these.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConcordIcon(
|
||||
val url: String? = null,
|
||||
@@ -123,10 +79,6 @@ data class ConcordIcon(
|
||||
val hash: String? = null,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Control fold
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
|
||||
@Serializable
|
||||
data class CommunityMeta(
|
||||
@@ -149,13 +101,6 @@ data class ChannelMeta(
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B).
|
||||
*
|
||||
* The tags are the edition machinery — `vsk` names the entity type, `eid` its stable coordinate,
|
||||
* `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as
|
||||
* a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization.
|
||||
*/
|
||||
data class ControlEdition(
|
||||
val vsk: Int,
|
||||
val eidHex: String,
|
||||
@@ -173,10 +118,6 @@ data class ControlFold(
|
||||
val channels: Map<String, ChannelMeta>,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Read surface
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
|
||||
data class ConcordChannel(
|
||||
val idHex: String,
|
||||
@@ -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(
|
||||
|
||||
@@ -15,85 +15,23 @@ import rust.nostr.sdk.nip44Decrypt
|
||||
import rust.nostr.sdk.nip44Encrypt
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The CORD-01 wire stack: `wrap → seal → rumor`.
|
||||
*
|
||||
* A stream is a shared key and a stream of gift wraps signed with it. Everything a plane sends
|
||||
* is three nested layers:
|
||||
*
|
||||
* ```
|
||||
* wrap kind 1059, signed by the stream key, one ephemeral `p` tag
|
||||
* └ nip44(conv_key) of
|
||||
* seal kind 20013 (encrypted) or 20014 (plaintext), signed by the real author
|
||||
* └ nip44(conv_key) of | byte-verbatim
|
||||
* rumor unsigned, its authority is the seal's signature around it
|
||||
* ```
|
||||
*
|
||||
* Both encrypted layers use the *same* conversation key — that is double encryption under one
|
||||
* key, not two, and it is what makes the wrap readable by anyone holding the plane key.
|
||||
*
|
||||
* Concord reverses NIP-59: the author is fixed (the stream key) and the `p` tag is ephemeral,
|
||||
* which is why the app's normal NIP-59 path in `MessageManager.extractRumor` cannot read these
|
||||
* and why routing happens by author before that code is reached.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which of CORD-01's two seal forms a plane uses; CORD-02 §5 makes this a fixed property of the
|
||||
* plane, "never a per-message choice". Because the plane owns it, [wrap] cannot pick the wrong
|
||||
* one and [unwrap] can reject a seal of the other form instead of quietly accommodating it.
|
||||
*/
|
||||
enum class SealForm(private val kindValue: Int) {
|
||||
/**
|
||||
* Chat, Guestbook, rekey. The rumor is NIP-44-encrypted inside the already-encrypted wrap,
|
||||
* so no relay — honest or malicious — can retain and display the rumor as a public event.
|
||||
*/
|
||||
Encrypted(ConcordKind.SEAL),
|
||||
|
||||
/**
|
||||
* Control plane only. The seal's content is the rumor's serialized JSON **byte-verbatim**,
|
||||
* because a signature over ciphertext is bound to the key that encrypted it and would break
|
||||
* if re-wrapped under another key across an epoch change.
|
||||
*/
|
||||
Plaintext(ConcordKind.PLAINTEXT_SEAL);
|
||||
|
||||
val kind: UShort get() = kindValue.toUShort()
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-03 §3: what a Chat-plane rumor must commit so a member cannot re-wrap another's message
|
||||
* into a different Channel or replay it across an epoch.
|
||||
*
|
||||
* The tags live *inside* the author-signed rumor, so the author's signature covers them; the
|
||||
* reader checks them strict-equal against the coordinate whose key opened the wrap.
|
||||
*/
|
||||
data class ChatBinding(val channelIdHex: String, val epoch: ULong)
|
||||
|
||||
/**
|
||||
* One plane's keys and, for a Chat plane, the coordinate its rumors are bound to.
|
||||
*
|
||||
* [read] decrypts: it is the conversation key for the wrap and for an encrypted seal. [stream]
|
||||
* is the address that signs wraps. They are the same key on a normal stream, and differ only on
|
||||
* a write-restricted one (CORD-01, used by the Control Plane in CORD-02 §5) — where a reader
|
||||
* holds the read key plus the writers' *pubkey*, enough to verify a wrap but not to mint one.
|
||||
*
|
||||
* Deliberately not a `data class`: there is no value equality worth having, and the default
|
||||
* `toString` keeps key material out of logs.
|
||||
*/
|
||||
class PlaneKey(
|
||||
val read: GroupKey,
|
||||
val stream: GroupKey?,
|
||||
/** The x-only hex of the key every wrap on this plane must be signed by. */
|
||||
val streamPublicKeyHex: String,
|
||||
/** CORD-02 §5: the one seal form this plane may use, on both the write and the read side. */
|
||||
val form: SealForm,
|
||||
val chat: ChatBinding?,
|
||||
)
|
||||
|
||||
/**
|
||||
* A Channel's Chat plane (CORD-03 §1). Public channels pass `community_root` as the secret,
|
||||
* Private ones their own independent `channel_key`; the `channel_id` in the derivation is what
|
||||
* gives each Channel a distinct address either way.
|
||||
*/
|
||||
fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
|
||||
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
|
||||
return PlaneKey(
|
||||
@@ -105,7 +43,6 @@ fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong
|
||||
)
|
||||
}
|
||||
|
||||
/** The community-wide Guestbook plane (CORD-02 §5), where joins, leaves and kicks are recorded. */
|
||||
fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
|
||||
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
|
||||
return PlaneKey(
|
||||
@@ -117,12 +54,6 @@ fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: U
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Control plane's **read** key (CORD-02 §5). Its wraps are signed by the staff-held
|
||||
* `control-signer` key instead, so this plane is read-only and [streamPublicKeyHex] is
|
||||
* whatever the invite claimed — nothing in an invite can prove it, so build nothing
|
||||
* security-relevant on it beyond the subscription address.
|
||||
*/
|
||||
fun controlPlaneKey(
|
||||
communityRoot: ByteArray,
|
||||
communityId: ByteArray,
|
||||
@@ -139,17 +70,6 @@ fun controlPlaneKey(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an unsigned rumor for this plane (CORD-01).
|
||||
*
|
||||
* The `channel`/`epoch` binding tags are stamped from the plane itself, and therefore from the
|
||||
* very key that will encrypt the wrap, so a rumor can never be built whose coordinate does not
|
||||
* match the key it travels under. `ms` rides every rumor (CORD-02 A.5) because `created_at` is
|
||||
* never tweaked — true time is `created_at * 1000 + ms`.
|
||||
*
|
||||
* A rumor is never signed and never a standalone artifact: its authority is the seal's
|
||||
* signature around it.
|
||||
*/
|
||||
fun PlaneKey.rumor(
|
||||
author: PublicKey,
|
||||
kind: UShort,
|
||||
@@ -172,47 +92,28 @@ fun PlaneKey.rumor(
|
||||
.ensureId()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps [rumor] into a publishable stream event.
|
||||
*
|
||||
* [signer] signs the *seal*, so it must be the real author's signer — the user's own key, which
|
||||
* also means a NIP-46 bunker works here. The wrap is signed by the plane's stream key instead,
|
||||
* and the seal form comes from the plane itself (CORD-02 §5).
|
||||
*
|
||||
* Both the seal and the wrap take the rumor's `created_at`, never a fresh one, so the three
|
||||
* layers agree and pagination by wrap timestamp lines up with message ordering.
|
||||
*/
|
||||
suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event {
|
||||
val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
|
||||
val createdAt = rumor.createdAt()
|
||||
|
||||
val rumorJson = rumor.asJson()
|
||||
val content = if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson
|
||||
|
||||
val seal = try {
|
||||
EventBuilder(
|
||||
Kind(form.kind),
|
||||
// CORD-01: byte-verbatim for a plaintext seal, so a re-wrap can carry the exact
|
||||
// signed bytes forward instead of re-serializing them.
|
||||
if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson,
|
||||
)
|
||||
.customCreatedAt(createdAt)
|
||||
.finalizeAsync(signer)
|
||||
EventBuilder(Kind(form.kind), content).customCreatedAt(createdAt).finalizeAsync(signer)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
|
||||
}
|
||||
|
||||
// The receiver drops a rumor whose author differs from its seal's, so publishing a mismatch
|
||||
// would produce a message nobody can read. Fail here instead, where the cause is visible.
|
||||
check(seal.author() == rumor.author()) {
|
||||
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${rumor.author().toHex()}"
|
||||
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${
|
||||
rumor.author().toHex()
|
||||
}"
|
||||
}
|
||||
|
||||
return try {
|
||||
// The ephemeral `p` is discarded: it only breaks linkage between a plane's wraps. Only
|
||||
// its pubkey is kept, and `Tag.publicKey` has already serialized it, so destroying the
|
||||
// keypair right away is safe. v1 has no giftwrap deletion, which is the one thing
|
||||
// CORD-01 §Deletions would want the secret for.
|
||||
val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
|
||||
Keys(stream.secretKey).use { keys ->
|
||||
EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
|
||||
@@ -227,19 +128,8 @@ suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a stream event and enforces every check a reader must make, returning null when any
|
||||
* of them fails.
|
||||
*
|
||||
* Null means **drop**, never retry: a wrong key, a forged or unverifiable signature, an
|
||||
* impersonation attempt, a decompression/serialization failure, or a `channel`/`epoch`
|
||||
* mismatch (CORD-03 §3). The checks are all here so a caller cannot skip one, and nothing is
|
||||
* rendered before they pass.
|
||||
*/
|
||||
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
||||
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
|
||||
// The wrap is signed by the stream key. Verifying this is what makes CORD-01's
|
||||
// write-restricted split real: a read-key holder can verify a wrap but cannot mint one.
|
||||
if (event.author().toHex() != streamPublicKeyHex) return null
|
||||
if (!event.verify()) return null
|
||||
|
||||
@@ -248,8 +138,6 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
||||
}.getOrNull() ?: return null
|
||||
if (!seal.verify()) return null
|
||||
|
||||
// CORD-02 §5 makes the seal form a fixed property of the plane, so a seal of the other form
|
||||
// is a discipline violation, not a variant to accommodate: only the matching pair is accepted.
|
||||
val sealKind = seal.kind().asU16()
|
||||
val rumorJson = when {
|
||||
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
|
||||
@@ -260,19 +148,14 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val rumor = runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
|
||||
val rumor =
|
||||
runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
|
||||
// NIP-59's impersonation check: the seal proves who wrote the rumor inside it.
|
||||
if (rumor.author() != seal.author()) return null
|
||||
if (!bindsToThisPlane(rumor)) return null
|
||||
return rumor
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-03 §3, strict-equal and fail-closed: on a Chat plane both tags must be present and match
|
||||
* this plane's coordinate, so neither a re-wrap into another Channel nor a cross-epoch replay
|
||||
* survives. The community-wide Guestbook and Control planes split no sub-context, so the spec
|
||||
* binds nothing there and this is a no-op.
|
||||
*/
|
||||
private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
|
||||
val expected = chat ?: return true
|
||||
val tags = rumor.tags().toVec()
|
||||
@@ -281,20 +164,9 @@ private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
|
||||
return channel == expected.channelIdHex && epoch == expected.epoch.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-02 A.5: true time is `created_at * 1000 + ms`, and a reader drops a rumor whose `ms`
|
||||
* falls outside `0..999`. `mod` rather than `%` so a pre-epoch timestamp cannot produce a
|
||||
* negative value.
|
||||
*/
|
||||
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
|
||||
|
||||
/**
|
||||
* NIP-44's plaintext cap, enforced by the publisher (CORD-01 §Encoding).
|
||||
*
|
||||
* Libraries are lenient and a lenient publisher mints events a strict reader cannot decrypt, so
|
||||
* this fails loudly at build time instead of producing an undecryptable message.
|
||||
*/
|
||||
private const val MAX_PLAINTEXT_BYTES = 65_535
|
||||
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
||||
|
||||
private fun nip44Seal(key: GroupKey, plaintext: String): String {
|
||||
val size = plaintext.encodeToByteArray().size
|
||||
@@ -304,5 +176,4 @@ private fun nip44Seal(key: GroupKey, plaintext: String): String {
|
||||
return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
|
||||
}
|
||||
|
||||
/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */
|
||||
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
||||
private const val MAX_PLAINTEXT_BYTES = 65_535
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Keys
|
||||
@@ -13,19 +11,7 @@ import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.nostr.Nostr
|
||||
|
||||
/**
|
||||
* Concord's local persistence, in the two places the rest of the app already keeps things.
|
||||
*
|
||||
* **Secrets → [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2),
|
||||
* so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go
|
||||
* through plaintext storage, and they never touch LMDB.
|
||||
*
|
||||
* **Community state → LMDB, as index events.** Decrypted plane rumors are cached the same way
|
||||
* [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the
|
||||
* Control fold has something to fold before the network answers.
|
||||
*/
|
||||
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||
|
||||
suspend fun loadMemberships(): List<Membership> {
|
||||
val raw = try {
|
||||
storage.getSecret(MEMBERSHIPS_KEY)
|
||||
@@ -54,27 +40,28 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the
|
||||
* community id for Control and Guestbook.
|
||||
*
|
||||
* The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable*
|
||||
* kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every
|
||||
* message would replace the one before it and history would vanish silently. The wrap id is
|
||||
* that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it —
|
||||
* and it doubles as the dedupe key when a wrap arrives from several relays.
|
||||
*/
|
||||
suspend fun clearMemberships() {
|
||||
try {
|
||||
storage.clear(MEMBERSHIPS_KEY)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Concord: could not clear memberships: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
|
||||
try {
|
||||
val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
||||
.tags(
|
||||
listOf(
|
||||
Tag.identifier(wrapIdHex),
|
||||
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
|
||||
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
|
||||
val event =
|
||||
EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
||||
.tags(
|
||||
listOf(
|
||||
Tag.identifier(wrapIdHex),
|
||||
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
|
||||
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
|
||||
)
|
||||
)
|
||||
)
|
||||
.finalizeAsync(Keys.generate())
|
||||
.finalizeAsync(Keys.generate())
|
||||
|
||||
nostr.client?.database()?.saveEvent(event)
|
||||
} catch (e: CancellationException) {
|
||||
@@ -99,14 +86,16 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return events
|
||||
.mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() }
|
||||
.mapNotNull {
|
||||
runCatching {
|
||||
UnsignedEvent.fromJson(it.content()).ensureId()
|
||||
}.getOrNull()
|
||||
}
|
||||
.sortedBy { it.createdAt().asSecs() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MEMBERSHIPS_KEY = "concord_memberships"
|
||||
|
||||
/** Single-letter index tags; `r` is what `Filter.reference` queries. */
|
||||
const val INDEX_SCOPE_TAG = "r"
|
||||
const val INDEX_KIND_TAG = "k"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package su.reya.coop.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.viewmodel.ErrorHost
|
||||
import su.reya.coop.viewmodel.createErrorHost
|
||||
|
||||
class ConcordRepository(
|
||||
private val nostr: Nostr,
|
||||
private val scope: CoroutineScope,
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
private val concord = nostr.concord
|
||||
|
||||
val communities: StateFlow<List<CommunityState>> = concord.communities
|
||||
|
||||
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = concord.directInvites
|
||||
|
||||
/** True once memberships have been loaded, so an empty list means "joined nothing". */
|
||||
val isReady: StateFlow<Boolean> = concord.restored
|
||||
|
||||
/** Bumped whenever a plane rumor lands, so an open Channel knows to re-read its history. */
|
||||
val revision: StateFlow<Long> = concord.revision
|
||||
|
||||
/** Fetches and decrypts the bundle behind a public invite link. */
|
||||
suspend fun previewInvite(link: String): InvitePreview? = attempt { concord.previewInvite(link) }
|
||||
|
||||
/** The same preview for a Direct Invite, which already arrived with its bundle in hand. */
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
||||
concord.previewDirectInvite(invite)
|
||||
|
||||
/** Accepts an invite: persist the keys, connect, subscribe, announce the join. */
|
||||
suspend fun join(preview: InvitePreview): CommunityState? = attempt { concord.join(preview) }
|
||||
|
||||
/** A Channel's history, oldest first, from the local cache the live subscription keeps current. */
|
||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
||||
attempt { concord.channelMessages(channelIdHex) }.orEmpty()
|
||||
|
||||
suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? =
|
||||
attempt { concord.sendChannelMessage(channelIdHex, content) }
|
||||
|
||||
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
|
||||
fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
|
||||
|
||||
fun dismissDirectInvite(communityIdHex: String) = concord.dismissDirectInvite(communityIdHex)
|
||||
|
||||
fun resetInternalState() {
|
||||
scope.launch(defaultDispatcher) { attempt { concord.reset() } }
|
||||
}
|
||||
|
||||
/** Runs one Concord call off the main thread, reporting any failure instead of throwing. */
|
||||
private suspend fun <T> attempt(block: suspend () -> T): T? = withContext(defaultDispatcher) {
|
||||
try {
|
||||
block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.concord.ConcordChannel
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
|
||||
class ChannelScreenViewModel(
|
||||
val communityId: String,
|
||||
val channelId: String,
|
||||
accountRepository: AccountRepository,
|
||||
private val concordRepository: ConcordRepository,
|
||||
) : ViewModel(), ErrorHost by concordRepository {
|
||||
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
|
||||
|
||||
val channel: StateFlow<ConcordChannel?> = concordRepository.communities
|
||||
.map { communities ->
|
||||
communities
|
||||
.firstOrNull { it.membership.communityId == communityId }
|
||||
?.channels
|
||||
?.firstOrNull { it.idHex == channelId }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
var loading by mutableStateOf(true)
|
||||
val messages = mutableStateListOf<ConcordMessage>()
|
||||
|
||||
private var reloadJob: Job? = null
|
||||
|
||||
init {
|
||||
reload()
|
||||
|
||||
viewModelScope.launch {
|
||||
concordRepository.revision.drop(1).collect { reload() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
reloadJob?.cancel()
|
||||
reloadJob = viewModelScope.launch {
|
||||
val loaded = concordRepository.channelMessages(channelId)
|
||||
if (messages.map { it.idHex } != loaded.map { it.idHex }) {
|
||||
messages.clear()
|
||||
messages.addAll(loaded)
|
||||
}
|
||||
loading = false
|
||||
concordRepository.markChannelRead(channelId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
viewModelScope.launch { concordRepository.sendMessage(channelId, text) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
|
||||
class ConcordViewModel(
|
||||
private val repository: ConcordRepository,
|
||||
) : ViewModel(), ErrorHost by repository {
|
||||
val communities: StateFlow<List<CommunityState>> = repository.communities
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = repository.directInvites
|
||||
val isReady: StateFlow<Boolean> = repository.isReady
|
||||
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
||||
repository.previewDirectInvite(invite)
|
||||
|
||||
suspend fun previewInvite(link: String): InvitePreview? = repository.previewInvite(link)
|
||||
|
||||
suspend fun join(preview: InvitePreview): CommunityState? = repository.join(preview)
|
||||
|
||||
fun dismissDirectInvite(communityIdHex: String) = repository.dismissDirectInvite(communityIdHex)
|
||||
|
||||
fun resetInternalState() = repository.resetInternalState()
|
||||
}
|
||||
Reference in New Issue
Block a user