add simple ui
This commit is contained in:
@@ -12,6 +12,24 @@ Implementation plan for adding **Concord** communities and channels to Coop.
|
||||
2. Reuse existing APIs and types from the nostr SDK; only create new code where the SDK has nothing.
|
||||
3. Follow the existing Coop architecture (no new patterns, no new DI framework, no new persistence layer).
|
||||
|
||||
**Status**
|
||||
|
||||
| Milestone | State |
|
||||
|---|---|
|
||||
| M1 — crypto core | ✅ `e221eda` |
|
||||
| M2 — plane stack | ✅ `8b0cbd2` |
|
||||
| M3 — join + read | ✅ `871efa6` |
|
||||
| M4 — write | ✅ `9230628` |
|
||||
| M5 — UI | ✅ code complete, compiles on both targets, **has never been run on a device** |
|
||||
| **M3/M4 interop smoke test** | ⬜ **not run — this is the acceptance gate** ([§12](#m3-interop-smoke-test--the-acceptance-gate), [§14](#14-verification-plan)) |
|
||||
| M4.5 — community creation | ⬜ optional, still unanswered |
|
||||
| M6 — reactions / edits | ⬜ optional |
|
||||
|
||||
**The one thing that matters most:** nothing has ever exchanged a message with a real Concord
|
||||
client. Every milestone is verified against itself and against the spec's byte layouts, which
|
||||
catches a typo in *our* code but not a misreading of the spec. Only the interop smoke test does
|
||||
that, and it needs a human with a second client.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
@@ -187,21 +205,28 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue
|
||||
|
||||
### 5.2 New — `shared/src/commonMain/kotlin/su/reya/coop/`
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `Community.kt` | UI-facing models + derived flows, mirroring `Room.kt` / `RoomUiState` |
|
||||
| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, `MutableStateFlow`, `stateIn(scope, WhileSubscribed(5000), …)` |
|
||||
| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel` |
|
||||
| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel` (`mutableStateListOf<UnsignedEvent>`) |
|
||||
|
||||
### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/`
|
||||
**Delivered in M5.**
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `CommunitiesScreen.kt` | Joined communities list + FAB → Join |
|
||||
| `CommunityScreen.kt` | Channel list for one community |
|
||||
| `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join |
|
||||
| `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states |
|
||||
| `Community.kt` | Display helpers over the read model — `CommunityState.displayName()` / `.unreadTotal()`, `ConcordMessage.timeLabel()` / `.dayLabel()`. Deliberately *not* a `RoomUiState`-style mirror: a Community's name is already in the fold, so there is no async lookup to model |
|
||||
| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, flows forwarded straight from the manager, and one private `attempt` funnel that hops to `defaultDispatcher` and reports instead of throwing |
|
||||
| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel`. `previewInvite` / `join` stay **suspend** so the Join screen owns its spinner |
|
||||
| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel`, but re-reads on the manager's `revision` counter since a plane message is not pushed as an event |
|
||||
|
||||
### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/`
|
||||
|
||||
**Delivered in M5.** All five live in one `communities` package, matching the `screens/chat/` convention.
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `CommunitiesScreen.kt` | Joined communities list + Direct Invite cards + FAB → Join |
|
||||
| `CommunityScreen.kt` | Channel list for one community, split public / private |
|
||||
| `ChannelScreen.kt` | Channel history, Discord-style. **Not in the original §5.3 list** — it was implied by the §11 table but omitted from the file plan |
|
||||
| `JoinCommunityScreen.kt` | Paste link / scan QR → preview card → Join |
|
||||
| `CommunityComponents.kt` | `CommunityRow`, `ChannelRow`, `DirectInviteCard`, `CommunityEmptyState`, `BetaNotice`, `shortCommunityId` |
|
||||
|
||||
**Deviation:** there is no `CommunityScreenViewModel`. That screen is a pure lookup of one `CommunityState` by id inside the existing `communities` flow, so an entry-scoped view model would have been a file with no state in it.
|
||||
|
||||
### 5.4 Tests — **none are kept**
|
||||
|
||||
@@ -209,17 +234,28 @@ Decision: no test files are maintained for this feature. M1–M4 were verified d
|
||||
|
||||
The consequence to plan around is in §14 and risk 13.
|
||||
|
||||
### 5.5 Modified (7 files, all small)
|
||||
### 5.5 Modified
|
||||
|
||||
Wired in M3 (before the UI existed):
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `shared/.../nostr/Nostr.kt` | add `val concord`, `init(dbPath, storage)`, 2 routing branches |
|
||||
| `shared/.../nostr/Nostr.kt` | `val concord`, `init(dbPath, storage)`, 2 routing branches |
|
||||
| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` |
|
||||
| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, add to `App(...)` params |
|
||||
| `composeApp/.../App.kt` | factory branch, `viewModel(...)`, 3 × `entry<…>`, snackbar collector |
|
||||
| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` |
|
||||
| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList` |
|
||||
| `composeApp/src/androidMain/composeResources/drawable/ic_communities.xml` | icon |
|
||||
|
||||
Wired in M5:
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `shared/.../concord/ConcordManager.kt` | `restored` StateFlow, `dismissDirectInvite`, `reset` |
|
||||
| `shared/.../concord/ConcordStore.kt` | `clearMemberships` |
|
||||
| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(communityId)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity(link)` |
|
||||
| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, passed into `App(...)` |
|
||||
| `composeApp/.../App.kt` | factory branch, activity-scoped `ConcordViewModel`, 4 × `entry<…>`, snackbar collector |
|
||||
| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList`; QR results routed by `parseInviteLink`; `concordViewModel.resetInternalState()` on logout |
|
||||
| `composeApp/.../screens/chat/ChatInput.kt` | `onUpload` / `onMicClick` became nullable, so a screen with neither shows a disabled send button instead of two dead ones |
|
||||
| `composeApp/.../composeResources/drawable/ic_communities.xml` | icon (empty state) |
|
||||
| `composeApp/.../composeResources/drawable/ic_lock.xml` | icon (private channel, beta notice) |
|
||||
|
||||
---
|
||||
|
||||
@@ -655,15 +691,18 @@ If a deep link is wanted later, add `coop://invite?url=<urlencoded>` and let Coo
|
||||
|
||||
## 11. UI
|
||||
|
||||
Mirrors the DM screens structurally so it feels native to Coop.
|
||||
Mirrors the DM screens structurally so it feels native to Coop. **Delivered in M5** — see
|
||||
[M5](#m5--ui--done) for what actually shipped and what did not.
|
||||
|
||||
| Screen | Mirrors | Notes |
|
||||
|---|---|---|
|
||||
| `CommunitiesScreen` | `HomeScreen` list + FAB | Community avatar (`Avatar`), name, channel count. Empty state per `ContactListScreen` convention. |
|
||||
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock icon on private. `#general` comes from genesis. |
|
||||
| `ChannelScreen` | `ChatScreen` | Reuse `DateSeparator`, `ChatInput`, `Avatar`. Discord-style (author shown per message) rather than Coop's DM style. |
|
||||
| `CommunitiesScreen` | `HomeScreen` list + FAB | Community name and channel count, plus Direct Invite cards above the list. **No avatar** for a Community: its icon is an encrypted blob v1 never fetches, so the row shows the placeholder rather than a picture it does not have. Empty state per `ContactListScreen` convention. |
|
||||
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock on private, `No key` on one we cannot read. `#general` comes from genesis. Carries the beta notice from §11.2. |
|
||||
| `ChannelScreen` | `ChatScreen` | Reuses `DateSeparator` and `ChatInput`. Discord-style (author shown per message) rather than Coop's DM style. The input is replaced by a line of text when the Channel has no key here. |
|
||||
| `JoinCommunityScreen` | `NewChatScreen` | Paste link or QR scan (reuse `LocalScanResult` / `Screen.Scan`), preview card, Join button. |
|
||||
|
||||
**Deviation:** `ChannelScreen` is not in the §5.3 file list even though this table always implied it — the file plan simply missed it.
|
||||
|
||||
### 11.1 Wiring, following existing conventions
|
||||
|
||||
```kotlin
|
||||
@@ -790,12 +829,34 @@ Owner mints `community_id`, `community_root`, `control_root`, genesis metadata (
|
||||
|
||||
**Why it might wait:** requires `control_root` handling and careful `20014` discipline.
|
||||
|
||||
### M5 — UI polish
|
||||
### M5 — UI — **done**
|
||||
|
||||
`ConcordRepository` / view models / 4 screens / `BottomMenuList` entry / icon / error snackbars.
|
||||
`ConcordRepository` / view models / 5 screens / `BottomMenuList` entry / 2 icons / error snackbars.
|
||||
|
||||
**Done when:** the whole flow works without adb logcat.
|
||||
|
||||
What shipped:
|
||||
|
||||
- **`ConcordRepository`** — flows forwarded from the manager, and one `attempt` funnel so no screen sees an exception. There is no state of its own: `ConcordManager` already pushes the read model, so a copy here would only be something to keep in sync.
|
||||
- **`ConcordViewModel`** (activity-scoped) and **`ChannelScreenViewModel`** (entry-scoped). The Channel one re-reads on `revision` rather than on an event, cancels any in-flight read so a burst of revisions cannot let an older snapshot land last, and only replaces the list when the ids actually changed — so a message in *another* community costs one indexed query and no recomposition.
|
||||
- **5 screens** in `screens/communities/`, mirroring `HomeScreen` / `ContactListScreen` / `ChatScreen` / `NewChatScreen` row for row.
|
||||
- **Reused, not reimplemented:** `ChatInput` and `DateSeparator` from `screens/chat`. `ChatInput`'s `onUpload` / `onMicClick` became nullable — Concord has neither in v1, and two live-looking buttons that do nothing would be worse than their absence.
|
||||
- **Two small backend additions the UI genuinely needed:** `restored`, because an empty membership list means the same thing before and after `restore()`; and `reset` / `clearMemberships`, because membership keys are identity-scoped and must not survive a logout (risk 17).
|
||||
- **Logout now drops Concord state.** `HomeScreen`'s logout path calls `resetInternalState()` alongside the account and chat ones.
|
||||
- **QR scan routes invite links.** `HomeScreen` checks `parseInviteLink` before `PublicKey.parse`, so scanning an invite lands on the Join screen; `JoinCommunityScreen` also reads `LocalScanResult` when reached from the Communities FAB.
|
||||
|
||||
**Verified:** see §14. The UI itself was verified by **compilation on both targets** — `:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` — and by nothing else. **No screen has been run.** `adb`/emulator is the only way to exercise the layout, the nav routes and the snackbar collector, and that has not been done.
|
||||
|
||||
### M5.5 — not done, and worth knowing
|
||||
|
||||
| Gap | Why it is not there |
|
||||
|---|---|
|
||||
| **No refresh / retry.** `sync()` is only called at startup and on join. A relay that refuses a subscription at startup is not retried until the app restarts. | Adding a button would not help: `sync()` only re-subscribes, it does not backfill. A real retry needs `fetchEvents`-based backfill on the plane addresses, which is a backend change |
|
||||
| **No message backfill.** History is whatever LMDB cached while subscribed. | The same missing `fetchEvents` path |
|
||||
| **No reactions, edits, replies, attachments, threads.** | M6 |
|
||||
| **No `@Preview`.** | None exist in the repo (B.8) |
|
||||
| **No deep link for `https://…/invite/…`.** | Would need an intent filter in the manifest; `coop://` handling stays as-is |
|
||||
|
||||
### M6 — Cheap wins (optional)
|
||||
|
||||
Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI. Threads (`kind 1111`) are more work — separate milestone.
|
||||
@@ -830,6 +891,8 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
| 14 | **`Nostr` is a Context-free singleton, but Concord's keys need `AppStorage`.** Neither the construction site (`NostrManager.instance`) nor the class has a `Context`. | `Nostr.init(dbPath, storage)` takes it and hands it to `ConcordManager.attach`. The foreground service is the only caller and already runs before any notification is handled, so the ordering is guaranteed. A second `AppStorage` instance in the M5 repository is fine — both wrap the same DataStore. |
|
||||
| 15 | **Unread badges live in memory only**, so a restart clears every one of them (§9.4). | This is the DM path's behaviour too (`Room.unreadCount` is likewise in-memory). Persisting it means a read-marker store, a new key and a new write path, all for a badge — worth doing only when a user asks for it. |
|
||||
| 16 | **The write path has no offline check.** `sendChannelMessage` needs a live `client`, so nothing kept in the repo exercises a publish, and the `MemoryStorage` fake used by the M4 check never touches Android Keystore. | Acceptance is interop smoke test step 4. Read and write share `PlaneKey.wrap`, and reading is verified independently (M2/M3), so a send-only failure localises to the publish: the relay set, or a relay dropping the wrap (risk 4). |
|
||||
| 17 | **Logout had to be taught about Concord.** `AccountRepository.logout` cleared the signer and wiped LMDB but nothing else, so the membership blob — which *is* the keys to every Community (CORD-02 §2) — would have survived into the next identity on the same device, silently granting it seats it never took. | `ConcordManager.reset()` clears the storage key, drops the subscriptions and re-indexes to nothing; `HomeScreen`'s logout path calls it. **Interactive confirmation is out of reach** — `logout` is not suspend and the reset is launched from it — so what is verified is that the call path exists and compiles, not that a logout actually erased the blob on a device. |
|
||||
| 18 | **`restored` must not flip back on reset.** If `reset()` set it false, the Communities screen would spin forever after a logout, because nothing re-runs `restore()` until the notification pump is rebuilt and the pump has no restart path. | `restored` means "memberships have been loaded", which stays true after they are dropped. Stated in a comment at the assignment. |
|
||||
|
||||
### 13.3 Open decision
|
||||
|
||||
@@ -839,7 +902,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
|
||||
## 14. Verification plan
|
||||
|
||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M4,
|
||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M5,
|
||||
then discarded; the tree carries no Concord sources under `commonTest` or `iosTest`. They are
|
||||
recorded here (and per-milestone in §12) because they are what establishes the wire format is
|
||||
right, and because whoever next touches this code should re-run the same checks.
|
||||
@@ -856,7 +919,7 @@ there is no regression net, so:
|
||||
- Anything SDK-free is deliberately kept in a pure function so it *could* be covered from
|
||||
`commonTest` (JVM and iOS, no device needed) if this decision is ever revisited.
|
||||
|
||||
### Checks that were run
|
||||
### 14.1 Checks that were run
|
||||
|
||||
| Check | Type | Ran on |
|
||||
|---|---|---|
|
||||
@@ -884,6 +947,10 @@ there is no regression net, so:
|
||||
| a delivered Channel message raises the badge; our own message and a non-message raise nothing | property | iOS ✅ M4 |
|
||||
| `markChannelRead` clears and republishes; a re-index carries the badge across | property | iOS ✅ M4 |
|
||||
| a sent rumor is exactly `channel`/`epoch`/`ms` at `kind 9`, refused by the Guestbook and another Channel | unit | iOS ✅ M4 |
|
||||
| `:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` after the M5 wiring, with the new classes confirmed present in `build/` | compile | Android + iOS ✅ M5 |
|
||||
| Icons render as intended | **not run** | needs a device |
|
||||
| Every M5 screen renders, navigates and snackbars | **not run** | needs `adb` |
|
||||
| Logout actually erases the membership blob | **not run** | needs a device (risk 17) |
|
||||
|
||||
**RFC 5869 Test Case 1** (for reference):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user