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):