add concord reactions and edits
This commit is contained in:
@@ -22,9 +22,12 @@ Implementation plan for adding **Concord** communities and channels to Coop.
|
||||
| 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 |
|
||||
|
||||
**Out of scope for this app: community creation.** Coop **joins** communities; it never mints one. An
|
||||
owner creates a community once — from a desktop client or a reference implementation — and hands
|
||||
out invites. See [§3.2](#32-deferred--explicitly-out-of-v1).
|
||||
|
||||
**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
|
||||
@@ -109,19 +112,22 @@ Concord is 8 CORDs. Building all at once contradicts requirement 1.
|
||||
|---|---|
|
||||
| **01** Private Streams | Full wrap/seal/rumor stack: `1059` / `20013` / `20014` + ephemeral `p` |
|
||||
| **02** Communities | `community_id`, `community_root`, `control_root` (held, not used), epochs, Control/Chat/Guestbook planes. **Read-only Control fold for `vsk 0` (metadata) + `vsk 2` (channels) only.** Guestbook: publish `join` on join, do not fold. |
|
||||
| **03** Channels | Public + Private, key derivation, `channel`/`epoch` binding checks, send/receive `kind 9` |
|
||||
| **03** Channels | Public + Private, key derivation, `channel`/`epoch` binding checks, send/receive `kind 9`, with `kind 7` reactions and `kind 3302` edits folded onto it (M6) |
|
||||
| **05** Invites | **Redeem only**: Direct Invite (`3313`) + public link bundle (`33301`) + fragment decoder. No minting. |
|
||||
|
||||
### 3.2 Deferred — explicitly out of v1
|
||||
|
||||
| CORD / feature | Why deferred |
|
||||
|---|---|
|
||||
| **Community creation** | **Decided, not deferred: out of scope for mobile.** Coop is a joiner. Owner-side minting (`community_id`, `community_root`, `control_root`, genesis `vsk 0` + `#general` `vsk 2`, written as `20014` plaintext seals) is a desktop/reference-client job. Two things follow from this and are worth stating: `concord/control-signer` is never used here, and the app cannot demo itself — there must be a real community minted elsewhere, which is exactly what the interop smoke test needs. |
|
||||
| **04** Roles | Roster fold + `vac` citation + outranking rules. Large. Needed for moderation, not for chat. |
|
||||
| **06** Rekeys | Epoch rotation. v1 reads the epoch it was invited to and holds old keys read-only. |
|
||||
| **05** minting | Invite List (`13303`), Registry (`vsk 8`), revocation tombstones. |
|
||||
| **07** A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. |
|
||||
| **08** Disappearing | Cheap to add later; `Tag.expiration` exists. |
|
||||
| Pins / Edits / Threads / WebXDC | Not needed for a first cut. |
|
||||
| Pins / Threads / WebXDC | Not needed for a first cut. |
|
||||
| Deletes (`kind 5`) | Registered, not folded: a message another client deleted still renders here. Same shape as M6's edits, so ~the same cost when someone asks. |
|
||||
| Replies (`kind 1111`) | Reuse `plane.key.rumor` the same way M6 does; the work is the thread UI, not the wire format. |
|
||||
| Community List (`33302`) | Cross-device sync; single-device + `AppStorage` covers v1. |
|
||||
| Dissolution | Rare path. |
|
||||
|
||||
@@ -257,6 +263,19 @@ Wired in M5:
|
||||
| `composeApp/.../composeResources/drawable/ic_communities.xml` | icon (empty state) |
|
||||
| `composeApp/.../composeResources/drawable/ic_lock.xml` | icon (private channel, beta notice) |
|
||||
|
||||
Wired in M6:
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `shared/.../concord/ConcordModels.kt` | `ConcordReaction`; `ConcordMessage.reactions` / `.edited`; `toChannelMessages()` fold |
|
||||
| `shared/.../concord/ConcordManager.kt` | `sendChannelReaction`, `sendChannelEdit`, and the private `channelPlane` / `publish` the three senders share |
|
||||
| `shared/.../concord/ConcordKind.kt` | `ConcordTag.E`; `ConcordTag.K`'s doc now covers both of its uses |
|
||||
| `shared/.../repository/ConcordRepository.kt` | `sendReaction`, `editMessage` |
|
||||
| `shared/.../viewmodel/ChannelScreenViewModel.kt` | `sendReaction`, `editMessage`; `reload` compares the whole list, not just its ids |
|
||||
| `composeApp/.../screens/chat/ChatScreen.kt` | `ReactionToolbar` no longer `private` |
|
||||
| `composeApp/.../screens/chat/ChatMessage.kt` | `MessageReactions` takes `(emojis, total)` instead of `List<ReactionGroup>`, so Channels can share it |
|
||||
| `composeApp/.../screens/communities/ChannelScreen.kt` | long-press action row, reaction chips, the Edit flow and its banner |
|
||||
|
||||
---
|
||||
|
||||
## 6. Crypto layer — exact algorithm
|
||||
@@ -383,7 +402,7 @@ This is a plain SHA-256 commitment, **not** the HKDF construction.
|
||||
| `concord/guestbook` | `community_root` | `community_id` | yes |
|
||||
| `concord/dissolved` | `community_id` | `0…0` | — |
|
||||
|
||||
Only `concord/channel`, `concord/guestbook`, and `concord/control` are exercised in v1. `concord/control-signer` is needed only if community creation is added ([M4.5](#m45--community-creation-optional)).
|
||||
Only `concord/channel`, `concord/guestbook`, and `concord/control` are exercised in v1. `concord/control-signer` is **unused**: it exists to sign Control editions, and nothing in Coop ever writes to the Control plane ([§3.2](#32-deferred--explicitly-out-of-v1)).
|
||||
|
||||
### 6.6 Encoding rules (CORD-01, normative)
|
||||
|
||||
@@ -697,8 +716,8 @@ Mirrors the DM screens structurally so it feels native to Coop. **Delivered in M
|
||||
| Screen | Mirrors | Notes |
|
||||
|---|---|---|
|
||||
| `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. |
|
||||
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock on private, `No key` on one we cannot read. `#general` arrives with the community's genesis metadata. Carries the beta notice from §11.2. |
|
||||
| `ChannelScreen` | `ChatScreen` | Reuses `DateSeparator` and `ChatInput`, plus `ReactionToolbar` and `MessageReactions` since M6. 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. **M6:** long-press for the emoji row, an Edit action on your own messages, chips under any message that has reactions. |
|
||||
| `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.
|
||||
@@ -727,8 +746,8 @@ And one entry in `BottomMenuList` (`HomeScreen.kt:785-791`) plus `ic_communities
|
||||
|
||||
### 11.2 UI honesty
|
||||
|
||||
- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community settings/info row.
|
||||
- Warn on community creation (if implemented) that losing the owner key kills the community — there is no succession by design.
|
||||
- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community info row. Delivered as `BetaNotice` on `CommunityScreen`.
|
||||
- Be explicit that Coop **joins** communities and never creates one ([§3.2](#32-deferred--explicitly-out-of-v1)). Nothing in the UI implies an owner-side flow exists, because there is no owner key here to hold: there is no create action anywhere, and `CommunitiesScreen`'s empty state says the only way in is an invite link.
|
||||
|
||||
---
|
||||
|
||||
@@ -797,6 +816,8 @@ Against a real Community, with a real invite link:
|
||||
2. join → the membership persists across a restart, the community's relays connect, and the Control plane folds metadata + the channel list
|
||||
3. **a message from another Concord client appears in Coop** — this is the one nothing else catches, since a label typo breaks interop silently
|
||||
4. a message sent from Coop appears there too — the M4 half of the same gate
|
||||
5. a reaction from that client groups under the message here, and one sent from Coop groups there — M6, and the same silent-failure shape as step 3
|
||||
6. an edit from that client replaces the text here with `(edited)` beside it — M6, and the *least* certain of the six, because `examples.md` calls the edit shape illustrative
|
||||
|
||||
If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1).
|
||||
If step 4 fails while step 3 passes, the read path is right and the fault is in the publish: the relay set, or the relay dropping these wraps (risk 4).
|
||||
@@ -821,13 +842,16 @@ What shipped:
|
||||
|
||||
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop. **This is a network criterion and has not been run** — see §14.
|
||||
|
||||
### M4.5 — Community creation (optional)
|
||||
### M4.5 — Community creation — **out of scope**
|
||||
|
||||
Owner mints `community_id`, `community_root`, `control_root`, genesis metadata (`vsk 0`) + `#general` (`vsk 2`); writes to the Control plane via the `20014` plaintext-seal path signed by `concord/control-signer`.
|
||||
Removed from the plan: mobile does not create communities. An owner mints `community_id`,
|
||||
`community_root`, `control_root` and the genesis editions once, from a desktop or reference client,
|
||||
and Coop's job is to join what that produces. The full rationale is in
|
||||
[§3.2](#32-deferred--explicitly-out-of-v1).
|
||||
|
||||
**Why consider it:** ~150 lines on top of M3, makes the feature demoable without depending on an existing Concord client, and exercises the Control-plane *write* path that join-only never touches.
|
||||
|
||||
**Why it might wait:** requires `control_root` handling and careful `20014` discipline.
|
||||
This was previously listed as "optional, ~150 lines on top of M3". The estimate was probably right,
|
||||
and it was genuinely tempting — it is the only way to exercise the Control-plane **write** path, and
|
||||
the only way to demo the feature without a second client. It is still the wrong shape for this app.
|
||||
|
||||
### M5 — UI — **done**
|
||||
|
||||
@@ -853,13 +877,52 @@ What shipped:
|
||||
|---|---|
|
||||
| **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 replies, attachments, threads, deletes.** | Reactions and edits landed in M6 (§12); the rest are not in v1. |
|
||||
| **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)
|
||||
### M6 — Reactions and edits — **done**
|
||||
|
||||
Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI. Threads (`kind 1111`) are more work — separate milestone.
|
||||
`kind 7` reactions and `kind 3302` edits, reusing the DM reaction UI. Threads (`kind 1111`) are still
|
||||
more work — separate milestone.
|
||||
|
||||
What shipped:
|
||||
|
||||
- **One write path, three kinds.** `sendChannelMessage` / `sendChannelReaction` / `sendChannelEdit`
|
||||
are all `plane.key.rumor` + `plane.key.wrap` behind one private `publish(plane, kind, content,
|
||||
extraTags)`. There is no second encrypter to keep in sync with the read path — the extra tags are
|
||||
the only thing that differs, and they are the only thing the read fold looks at.
|
||||
- **Tags exactly as `examples.md` §2.3 / §2.5 has them**, which is the whole interop risk here:
|
||||
a reaction is `e` (target **rumor** id, never the wrap's) + `p` (target's author) + `k` `"9"`;
|
||||
an edit is `e` alone. Both ride the ordinary encrypted seal at the Channel address, so an edit or
|
||||
reaction reaches exactly the readers the original message did.
|
||||
- **The fold is in `toChannelMessages()`**, next to `toConcordMessage()`. Messages are projected
|
||||
first, then reactions and edits are folded on by target id. An edit is applied only when its author
|
||||
matches the message's — the `e` tag is a claim, the seal's author is the proof — and the latest
|
||||
edit by `timestampMs` wins. A reaction for a message we no longer hold is dropped with it.
|
||||
- **`ReactionToolbar` and `MessageReactions` are now shared**, not DM-only. `MessageReactions` lost
|
||||
its `ReactionGroup` parameter and takes `(emojis, total)`, which is all it ever drew — the same
|
||||
shape of change M5 made to `ChatInput` when Concord had no upload or mic. `ReactionToolbar` is
|
||||
unchanged, just no longer `private`.
|
||||
- **Long-press a message** for the emoji row; an Edit action appears on your own messages, which
|
||||
puts the replacement text in the input under an "Editing a message" banner. Tap anywhere to
|
||||
dismiss. This is an inline row rather than the DM screen's floating toolbar — the DM one is
|
||||
anchored with window coordinates and a backdrop, and none of that buys anything in a plain
|
||||
`LazyColumn`.
|
||||
- **A latent M5 bug, fixed:** `ChannelScreenViewModel.reload` compared only message *ids*, so a
|
||||
reaction or an edit — which change a message without changing which messages there are — would
|
||||
have been fetched and then thrown away. It compares the whole list now.
|
||||
|
||||
**Verified:** compilation on both targets, as M5 was — `:shared:compileKotlinIosSimulatorArm64`,
|
||||
`:shared:compileDebugKotlinAndroid` and `:composeApp:compileDebugKotlinAndroid`, with the recompiled
|
||||
`composeApp` classes confirmed by timestamp (a cached `BUILD SUCCESSFUL in <1s` on `:composeApp`
|
||||
proves nothing — §14.1).
|
||||
|
||||
**Not verified, and worth saying out loud:** this is the first milestone whose *entire* output is a
|
||||
wire format nothing in this repository can check. `examples.md` marks the edit shape explicitly
|
||||
illustrative ("The CORDs register the kind but don't yet pin its fields"), and a wrong `e`-tag or
|
||||
`k`-tag convention fails silently — the reaction simply never groups. The interop smoke test below
|
||||
gains two more steps because of it.
|
||||
|
||||
---
|
||||
|
||||
@@ -879,7 +942,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
| # | Risk | Mitigation |
|
||||
|---|---|---|
|
||||
| 4 | **Relay compatibility.** Reversed NIP-59 wraps may be dropped by relays enforcing the `p`-tag guard. | Always use the community's relay set from the invite. Never the app defaults. |
|
||||
| 5 | **No owner recovery, by design.** `community_id` commits to the owner's key; lose it and the community is dead. | Surface a backup warning on community creation. |
|
||||
| 5 | **No owner recovery, by design.** `community_id` commits to the owner's key; lose it and the community is dead. | Not a risk Coop can mitigate — it never holds an owner key, because it never creates a community. Worth stating on the Join screen so nobody expects a rescue path. |
|
||||
| 6 | **`control_pk` is taken on trust** at join — nothing in the invite can prove it. | Build nothing security-relevant on it beyond the subscription address. |
|
||||
| 7 | **v1 has no authority enforcement.** Staff hold `control_root`, so blast radius is limited, but a hostile staffer can forge metadata/channels. | Document in code; label beta in UI; close with a CORD-04 milestone. |
|
||||
| 8 | **One notification pump.** A second `client.notifications()` consumer would silently split subscriptions. | Comment on `handleNotifications` stating it must stay the only consumer. |
|
||||
@@ -893,16 +956,21 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
| 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. |
|
||||
| 19 | **M6's edit shape is illustrative, not normative.** `examples.md` §2.5 says so in as many words: "The CORDs register the kind but don't yet pin its fields; this shape is illustrative." The reaction shape (§2.3) is pinned — NIP-25 with `e`/`p`/`k` — but the edit's `e`-alone layout is a guess the spec invites others to make differently. | Unfixable from here; it is a spec gap, not an implementation choice. Both shapes are one call to `publish` with a different tag list, so a revision is a one-place change (§11's frozen-constants discipline). Report it upstream rather than papering over it. |
|
||||
| 20 | **Enforcement of edits is local.** Our fold refuses an edit whose author is not the message's, but nothing stops another client rendering a forged one — and nothing *should*, since a receiver-side check is the whole enforcement model. | This is the protocol, not a bug: enforcement is rejection ("an action that does not trace to the owner is not authority"). The one thing to keep is the check itself, so a hostile edit never lands *here*. |
|
||||
|
||||
### 13.3 Open decision
|
||||
### 13.3 Closed decision
|
||||
|
||||
**Community creation in v1?** See [M4.5](#m45--community-creation-optional). Recommendation: yes, after M4 — it makes the feature demoable standalone.
|
||||
**Community creation: out of scope.** Coop joins communities and never creates one. Rationale in
|
||||
[§3.2](#32-deferred--explicitly-out-of-v1). Consequences: `concord/control-signer` stays unused, the
|
||||
Control-plane write path is never exercised, and there is no way to demo the feature without a real
|
||||
community minted elsewhere — which is the same dependency the interop smoke test already has.
|
||||
|
||||
---
|
||||
|
||||
## 14. Verification plan
|
||||
|
||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M5,
|
||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M6,
|
||||
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.
|
||||
@@ -948,6 +1016,9 @@ there is no regression net, so:
|
||||
| `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 |
|
||||
| The same three tasks after the M6 fold + UI, with the recompiled classes confirmed by timestamp | compile | Android + iOS ✅ M6 |
|
||||
| A reaction or edit sent from Coop is understood by another Concord client | **not run** | needs a second client |
|
||||
| A reaction or edit from another client folds into Coop's timeline | **not run** | needs a second client |
|
||||
| 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) |
|
||||
@@ -978,7 +1049,7 @@ OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d20139
|
||||
using even though Concord never asks for more than 32 bytes: it is the only vector that drives
|
||||
Expand across three blocks, and it is what caught the empty-salt handling.
|
||||
|
||||
Additionally, run one **interop smoke test** against a real community before declaring M3 done. Nothing catches a label typo faster.
|
||||
Additionally, run one **interop smoke test** against a real community before declaring the feature done. Nothing catches a label typo faster.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user