add simple ui

This commit is contained in:
2026-09-15 11:07:41 +07:00
parent 9230628441
commit 63ef074e8f
25 changed files with 1751 additions and 635 deletions
+94 -27
View File
@@ -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. M1M4 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 M1M4,
**No test files are kept** (decision, M2). The checks below were written and run during M1M5,
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
}
@@ -0,0 +1,185 @@
package su.reya.coop.screens.communities
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.displayName
import su.reya.coop.viewmodel.ConcordViewModel
/**
* Every Community this device has joined, plus any Direct Invite still waiting.
*
* There is no file upload, no message search and no per-Community settings page v1 is the read
* path and sending, and the screen says so rather than pretending otherwise.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CommunitiesScreen(concordViewModel: ConcordViewModel) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
val directInvites by concordViewModel.directInvites.collectAsStateWithLifecycle()
val isReady by concordViewModel.isReady.collectAsStateWithLifecycle()
val sorted = remember(communities) { communities.sortedBy { it.displayName().lowercase() } }
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Communities",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
},
floatingActionButton = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
tooltip = {
PlainTooltip { Text("Join a community") }
},
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = { navigator.navigate(Screen.JoinCommunity()) },
expanded = false,
icon = {
Icon(
painter = painterResource(Res.drawable.ic_plus),
contentDescription = "Join a community"
)
},
text = { Text("Join") },
)
}
},
content = { innerPadding ->
when {
!isReady && sorted.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
}
sorted.isEmpty() && directInvites.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
CommunityEmptyState(
title = "No communities yet",
subtitle = "Join one with an invite link.",
)
}
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
items(directInvites, key = { it.communityId }) { invite ->
DirectInviteCard(
invite = invite,
onJoin = {
scope.launch {
// A Direct Invite already carries its bundle, so there is
// nothing to fetch — only to validate and accept.
val preview = concordViewModel.previewDirectInvite(invite)
val joined = concordViewModel.join(preview)
if (joined != null) {
navigator.navigate(
Screen.Community(joined.membership.communityId)
)
}
}
},
onDismiss = {
concordViewModel.dismissDirectInvite(invite.communityId)
},
)
}
items(sorted, key = { it.membership.communityId }) { state ->
CommunityRow(
state = state,
onClick = {
navigator.navigate(
Screen.Community(state.membership.communityId)
)
},
)
}
}
}
}
},
)
}
@@ -0,0 +1,254 @@
package su.reya.coop.screens.communities
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_communities
import coop.composeapp.generated.resources.ic_lock
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.concord.CommunityInvite
import su.reya.coop.concord.CommunityState
import su.reya.coop.concord.ConcordChannel
import su.reya.coop.displayName
import su.reya.coop.shared.Avatar
import su.reya.coop.unreadTotal
/**
* Rows and small blocks shared by the Communities screens, mirroring `HomeScreen`'s `ChatRoom` and
* `ContactListItem`: `ListItem` for the top level, `SegmentedListItem` inside a group.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CommunityRow(state: CommunityState, onClick: () -> Unit) {
val unread = state.unreadTotal()
ListItem(
modifier = Modifier.clickable(onClick = onClick),
leadingContent = {
BadgedBox(
badge = {
if (unread > 0) {
Badge { Text(unread.toString()) }
}
}
) {
// Concord icons are encrypted blobs v1 never fetches, so this is always the placeholder.
Avatar(picture = null, description = state.displayName())
}
},
headlineContent = {
Text(
text = state.displayName(),
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text = channelCountLabel(state.channels.size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChannelRow(channel: ConcordChannel, index: Int, total: Int, onClick: () -> Unit) {
val unread = channel.unreadCount
SegmentedListItem(
onClick = onClick,
shapes = ListItemDefaults.segmentedShapes(index = index, count = total),
leadingContent = {
BadgedBox(
badge = {
if (unread > 0) {
Badge { Text(unread.toString()) }
}
}
) {
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
if (channel.private) {
Icon(
painter = painterResource(Res.drawable.ic_lock),
contentDescription = "Private",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.outline,
)
} else {
Text(
text = "#",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
},
content = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = channel.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// A Private Channel with no key is listable but unreadable: the row says so rather
// than opening an empty room and looking broken.
if (!channel.hasKey) {
Text(
text = "No key",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
)
}
/**
* A Direct Invite (CORD-05 §6) waiting to be accepted. It arrived giftwrapped to this npub, so it is
* shown on the Communities list rather than in any chat.
*/
@Composable
fun DirectInviteCard(invite: CommunityInvite, onJoin: () -> Unit, onDismiss: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Invited to a community",
style = MaterialTheme.typography.labelMedium,
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = invite.name ?: "Untitled community",
style = MaterialTheme.typography.titleMediumEmphasized,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.size(2.dp))
Text(
text = shortCommunityId(invite.communityId),
style = MaterialTheme.typography.bodySmall,
)
Spacer(modifier = Modifier.size(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onDismiss) { Text("Dismiss") }
Button(onClick = onJoin) { Text("Join") }
}
}
}
}
/** The app's empty-state convention: two centred lines, with the feature's mark above them. */
@Composable
fun CommunityEmptyState(title: String, subtitle: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
painter = painterResource(Res.drawable.ic_communities),
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.outlineVariant,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
/**
* Says out loud what v1 does not do yet. Concord's authority is a signed roster every client folds
* (CORD-04), and this build folds none of it a stale claim of moderation would be worse than none.
*/
@Composable
fun BetaNotice() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(Res.drawable.ic_lock),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.size(12.dp))
Text(
text = "Beta. Messages, channels and invites work. Roles, kicks and bans are not " +
"enforced by this build yet — only by the other clients in the community.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
private fun channelCountLabel(count: Int): String =
if (count == 1) "1 channel" else "$count channels"
/** A Community's identity is a hash, so show enough of it to tell two apart. */
internal fun shortCommunityId(communityId: String): String =
if (communityId.length <= 16) communityId else communityId.take(16) + "..."
@@ -0,0 +1,171 @@
package su.reya.coop.screens.communities
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.displayName
import su.reya.coop.viewmodel.ConcordViewModel
/**
* One Community's Channels, split public and private the way the protocol splits them: a Public
* Channel's key derives from `community_root`, so anyone in the Community can read it, while a
* Private one is only listable to those an invite or a rekey handed a key to (CORD-03 §1).
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CommunityScreen(communityId: String, concordViewModel: ConcordViewModel) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
val state = remember(communities, communityId) {
communities.firstOrNull { it.membership.communityId == communityId }
}
if (state == null) {
// Reachable if the Community was dropped underneath this screen (a logout while it was open),
// so it keeps a way back rather than dead-ending here.
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
TopAppBar(
title = {
Text(
text = "Community",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
text = "This community is not on this device.",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
return
}
val (privateChannels, publicChannels) = remember(state.channels) {
state.channels.partition { it.private }
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = state.displayName(),
style = MaterialTheme.typography.titleMediumEmphasized,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
},
content = { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
item { BetaNotice() }
itemsIndexed(publicChannels) { index, channel ->
ChannelRow(
channel = channel,
index = index,
total = publicChannels.size,
onClick = {
navigator.navigate(Screen.Channel(communityId, channel.idHex))
},
)
}
itemsIndexed(privateChannels) { index, channel ->
ChannelRow(
channel = channel,
index = index,
total = privateChannels.size,
onClick = {
navigator.navigate(Screen.Channel(communityId, channel.idHex))
},
)
}
if (state.channels.isEmpty()) {
item {
Text(
text = "No channels yet. They arrive with the community's metadata.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
},
)
}
@@ -0,0 +1,237 @@
package su.reya.coop.screens.communities
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.concord.InvitePreview
import su.reya.coop.viewmodel.ConcordViewModel
/**
* Paste or scan an invite link, see what joining would mean, then join.
*
* The preview is deliberately a separate step: a link is fetched and decrypted, but nothing is
* subscribed and no presence is announced until the user accepts (CORD-05 §1), so backing out here
* leaves no trace on any relay.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun JoinCommunityScreen(concordViewModel: ConcordViewModel, initialLink: String? = null) {
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
var link by remember(initialLink) { mutableStateOf(initialLink.orEmpty()) }
var preview by remember { mutableStateOf<InvitePreview?>(null) }
var isBusy by remember { mutableStateOf(false) }
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { scanned ->
link = scanned
preview = null
qrScanResult.clear()
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Join a community",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
actions = {
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
},
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = link,
onValueChange = {
link = it
// Any edit invalidates what was fetched for the old text.
preview = null
},
modifier = Modifier.fillMaxWidth(),
label = { Text("Invite link") },
placeholder = { Text("https://.../invite/...") },
maxLines = 4,
)
Button(
onClick = {
scope.launch {
isBusy = true
preview = concordViewModel.previewInvite(link)
isBusy = false
}
},
enabled = link.isNotBlank() && !isBusy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Look it up")
}
preview?.let { shown ->
InvitePreviewCard(
preview = shown,
isBusy = isBusy,
onJoin = {
scope.launch {
isBusy = true
val joined = concordViewModel.join(shown)
isBusy = false
if (joined != null) {
// Replace this screen with the Community it just joined.
navigator.goBack()
navigator.navigate(
Screen.Community(joined.membership.communityId)
)
}
}
},
)
}
}
},
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun InvitePreviewCard(preview: InvitePreview, isBusy: Boolean, onJoin: () -> Unit) {
val invite = preview.invite
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = invite.name ?: "Untitled community",
style = MaterialTheme.typography.titleMediumEmphasized,
)
Text(
text = shortCommunityId(invite.communityId),
style = MaterialTheme.typography.bodySmall,
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = plural(invite.channels.size, "channel"),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = plural(preview.relays.size, "relay"),
style = MaterialTheme.typography.bodyMedium,
)
// Everything that would stop this invite from being accepted, in plain English.
preview.problems.forEach { problem -> Refusal(problem) }
if (preview.expired) Refusal("This invite has expired.")
if (preview.alreadyJoined) {
Text(
text = "You are already in this community.",
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.size(8.dp))
Button(
onClick = onJoin,
enabled = preview.joinable && !isBusy,
modifier = Modifier.fillMaxWidth(),
) {
if (isBusy) {
LoadingIndicator(modifier = Modifier.size(20.dp))
} else {
Text(if (preview.alreadyJoined) "Rejoin" else "Join")
}
}
}
}
}
@Composable
private fun Refusal(message: String) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
private fun plural(count: Int, noun: String): String =
if (count == 1) "1 $noun" else "$count ${noun}s"
@@ -0,0 +1,46 @@
package su.reya.coop
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import su.reya.coop.concord.CommunityState
import su.reya.coop.concord.ConcordMessage
import kotlin.time.Clock
/** Control metadata first, then the snapshot the invite carried, then a last-resort label. */
fun CommunityState.displayName(): String {
val name = meta?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
?: membership.name?.sanitizeName()?.takeIf { it.isNotBlank() }
return name ?: "Untitled community"
}
/** Every Channel's badge added up, for the Community row. */
fun CommunityState.unreadTotal(): Int = channels.sumOf { it.unreadCount }
/** `HH:mm`, local time. */
fun ConcordMessage.timeLabel(): String {
val time = createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
val hour = time.hour.toString().padStart(2, '0')
val minute = time.minute.toString().padStart(2, '0')
return "$hour:$minute"
}
/** `Today` / `Yesterday` / `DD/MM/YY`, matching [formatAsGroup] for DMs. */
fun ConcordMessage.dayLabel(): String {
val zone = TimeZone.currentSystemDefault()
val date = createdAt.toLocalDateTime(zone).date
val today = Clock.System.now().toLocalDateTime(zone).date
return when (date) {
today -> "Today"
today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
else -> {
val day = date.day.toString().padStart(2, '0')
val month = date.month.number.toString().padStart(2, '0')
val year = date.year.toString().takeLast(2)
"$day/$month/$year"
}
}
}
@@ -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()
}