add concord reactions and edits

This commit is contained in:
2026-09-15 15:17:14 +07:00
parent 63ef074e8f
commit 5b72b07ca8
9 changed files with 349 additions and 63 deletions
+92 -21
View File
@@ -22,9 +22,12 @@ Implementation plan for adding **Concord** communities and channels to Coop.
| M4 — write | ✅ `9230628` | | M4 — write | ✅ `9230628` |
| M5 — UI | ✅ code complete, compiles on both targets, **has never been run on a device** | | 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)) | | **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 | | 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 **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 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 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` | | **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. | | **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. | | **05** Invites | **Redeem only**: Direct Invite (`3313`) + public link bundle (`33301`) + fragment decoder. No minting. |
### 3.2 Deferred — explicitly out of v1 ### 3.2 Deferred — explicitly out of v1
| CORD / feature | Why deferred | | 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. | | **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. | | **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. | | **05** minting | Invite List (`13303`), Registry (`vsk 8`), revocation tombstones. |
| **07** A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. | | **07** A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. |
| **08** Disappearing | Cheap to add later; `Tag.expiration` exists. | | **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. | | Community List (`33302`) | Cross-device sync; single-device + `AppStorage` covers v1. |
| Dissolution | Rare path. | | Dissolution | Rare path. |
@@ -257,6 +263,19 @@ Wired in M5:
| `composeApp/.../composeResources/drawable/ic_communities.xml` | icon (empty state) | | `composeApp/.../composeResources/drawable/ic_communities.xml` | icon (empty state) |
| `composeApp/.../composeResources/drawable/ic_lock.xml` | icon (private channel, beta notice) | | `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 ## 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/guestbook` | `community_root` | `community_id` | yes |
| `concord/dissolved` | `community_id` | `0…0` | — | | `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) ### 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 | | 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. | | `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. | | `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`. 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. | | `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. | | `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. **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 ### 11.2 UI honesty
- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community settings/info row. - Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community info row. Delivered as `BetaNotice` on `CommunityScreen`.
- Warn on community creation (if implemented) that losing the owner key kills the community — there is no succession by design. - 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 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 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 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 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). 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. **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. 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
**Why it might wait:** requires `control_root` handling and careful `20014` discipline. the only way to demo the feature without a second client. It is still the wrong shape for this app.
### M5 — UI — **done** ### 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 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 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 `@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 | | **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 | | # | 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. | | 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. | | 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. | | 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. | | 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). | | 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. | | 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. | | 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 ## 14. Verification plan
**No test files are kept** (decision, M2). The checks below were written and run during M1M5, **No test files are kept** (decision, M2). The checks below were written and run during M1M6,
then discarded; the tree carries no Concord sources under `commonTest` or `iosTest`. They are 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 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. 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 | | `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 | | 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 | | `: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 | | Icons render as intended | **not run** | needs a device |
| Every M5 screen renders, navigates and snackbars | **not run** | needs `adb` | | Every M5 screen renders, navigates and snackbars | **not run** | needs `adb` |
| Logout actually erases the membership blob | **not run** | needs a device (risk 17) | | 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 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. 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.
--- ---
@@ -229,7 +229,8 @@ fun ChatMessage(
if (model.reactions.isNotEmpty()) { if (model.reactions.isNotEmpty()) {
MessageReactions( MessageReactions(
reactions = model.reactions, emojis = model.reactions.map { it.emoji },
total = model.reactions.sumOf { it.authors.size },
modifier = Modifier.offset(y = 12.dp) modifier = Modifier.offset(y = 12.dp)
) )
} }
@@ -247,13 +248,14 @@ fun ChatMessage(
} }
} }
/** The reaction chips under a bubble, shared by DMs and Channels. */
@Composable @Composable
private fun MessageReactions( fun MessageReactions(
reactions: List<ReactionGroup>, emojis: List<String>,
total: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val totalCount = reactions.sumOf { it.authors.size } val displayEmojis = emojis.take(3)
val displayEmojis = reactions.take(3).map { it.emoji }
Row( Row(
modifier = modifier, modifier = modifier,
@@ -274,7 +276,7 @@ private fun MessageReactions(
} }
} }
} }
if (totalCount > 2) { if (total > 2) {
Surface( Surface(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
@@ -282,7 +284,7 @@ private fun MessageReactions(
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Text(
text = totalCount.toString(), text = total.toString(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp, fontSize = 10.sp,
) )
@@ -650,7 +650,7 @@ private fun ReplyPreview(
} }
@Composable @Composable
private fun ReactionToolbar( fun ReactionToolbar(
onReaction: (String) -> Unit onReaction: (String) -> Unit
) { ) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉") val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
@@ -1,5 +1,7 @@
package su.reya.coop.screens.communities package su.reya.coop.screens.communities
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -28,6 +30,7 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -56,6 +59,8 @@ import su.reya.coop.dayLabel
import su.reya.coop.sanitizeName import su.reya.coop.sanitizeName
import su.reya.coop.screens.chat.ChatInput import su.reya.coop.screens.chat.ChatInput
import su.reya.coop.screens.chat.DateSeparator import su.reya.coop.screens.chat.DateSeparator
import su.reya.coop.screens.chat.MessageReactions
import su.reya.coop.screens.chat.ReactionToolbar
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.short import su.reya.coop.short
import su.reya.coop.timeLabel import su.reya.coop.timeLabel
@@ -81,6 +86,12 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
/** The message whose action row is open, if any. */
var activeMessageId by remember { mutableStateOf<String?>(null) }
/** The message an Edit is being written for, if any. Its text lives in [text]. */
var editing by remember { mutableStateOf<ConcordMessage?>(null) }
val grouped by remember { val grouped by remember {
derivedStateOf { derivedStateOf {
viewModel.messages viewModel.messages
@@ -193,6 +204,18 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
ChannelMessageRow( ChannelMessageRow(
message = message, message = message,
isMine = message.author == currentUser?.publicKey?.toHex(), isMine = message.author == currentUser?.publicKey?.toHex(),
active = activeMessageId == message.idHex,
onDismissActions = { activeMessageId = null },
onLongPress = { activeMessageId = message.idHex },
onReaction = { emoji ->
viewModel.sendReaction(message, emoji)
activeMessageId = null
},
onEdit = {
editing = message
text = message.content
activeMessageId = null
},
) )
} }
item(key = "day:$day") { DateSeparator(day) } item(key = "day:$day") { DateSeparator(day) }
@@ -211,13 +234,33 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
} else { } else {
if (editing != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Editing a message",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
TextButton(onClick = { editing = null; text = "" }) { Text("Cancel") }
}
}
// No attachments and no dictation in v1, so the input shows neither: a button // No attachments and no dictation in v1, so the input shows neither: a button
// that does nothing would be worse than its absence. // that does nothing would be worse than its absence.
ChatInput( ChatInput(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
onSend = { onSend = {
viewModel.sendMessage(text) val target = editing
if (target == null) viewModel.sendMessage(text)
else viewModel.editMessage(target.idHex, text)
editing = null
text = "" text = ""
}, },
) )
@@ -229,7 +272,15 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
} }
@Composable @Composable
private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) { private fun ChannelMessageRow(
message: ConcordMessage,
isMine: Boolean,
active: Boolean,
onDismissActions: () -> Unit,
onLongPress: () -> Unit,
onReaction: (String) -> Unit,
onEdit: () -> Unit,
) {
val (pubkey, profile) = rememberAuthor(message.author) val (pubkey, profile) = rememberAuthor(message.author)
val name = profile?.name?.sanitizeName()?.takeIf { it.isNotBlank() } val name = profile?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
?: pubkey?.short() ?: pubkey?.short()
@@ -242,7 +293,16 @@ private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) {
) { ) {
Avatar(picture = profile?.picture, description = name, size = 36.dp) Avatar(picture = profile?.picture, description = name, size = 36.dp)
Spacer(modifier = Modifier.size(10.dp)) Spacer(modifier = Modifier.size(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(
modifier = Modifier
.weight(1f)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismissActions,
onLongClick = onLongPress,
)
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = if (isMine) "$name (you)" else name, text = if (isMine) "$name (you)" else name,
@@ -258,12 +318,36 @@ private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
if (message.edited) {
Spacer(modifier = Modifier.size(4.dp))
Text(
text = "(edited)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
} }
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
if (message.reactions.isNotEmpty()) {
MessageReactions(
emojis = message.reactions.map { it.emoji },
total = message.reactions.sumOf { it.authors.size },
modifier = Modifier.padding(top = 4.dp),
)
}
if (active) {
Column(modifier = Modifier.padding(top = 8.dp)) {
ReactionToolbar(onReaction = onReaction)
// Only the author may edit, so only they are offered it.
if (isMine) {
TextButton(onClick = onEdit) { Text("Edit") }
}
}
}
} }
} }
} }
@@ -222,6 +222,9 @@ object ConcordTag {
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */ /** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
const val QUOTE = "q" const val QUOTE = "q"
/** `["e", "<rumor id>"]` — the message a reaction, edit or delete targets (CORD-03 §2.32.5). */
const val E = "e"
/** `["vsk", "<n>"]` — Control edition entity type. */ /** `["vsk", "<n>"]` — Control edition entity type. */
const val VSK = "vsk" const val VSK = "vsk"
@@ -249,6 +252,6 @@ object ConcordTag {
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */ /** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
const val INVITE = "invite" const val INVITE = "invite"
/** `["k", "3313"]` — the one deliberate outer-tag exception on a Direct Invite wrap. */ /** `["k", "<kind>"]` — the target's kind on a reaction, the invite hint on a `3313` wrap. */
const val K = "k" const val K = "k"
} }
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Event import rust.nostr.sdk.Event
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind import rust.nostr.sdk.Kind
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -283,14 +284,9 @@ class ConcordManager(private val nostr: Nostr) {
?: CommunityState(membership, null, emptyList()) ?: CommunityState(membership, null, emptyList())
} }
/** /** A Channel's messages, oldest first, with reactions and edits folded on top. */
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
* keeps it current.
*/
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> = suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
store?.cachedRumors(channelIdHex).orEmpty() store?.cachedRumors(channelIdHex).orEmpty().toChannelMessages()
.mapNotNull { it.toConcordMessage() }
.sortedBy { it.timestampMs }
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */ /** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0 fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
@@ -303,34 +299,79 @@ class ConcordManager(private val nostr: Nostr) {
} }
/** /**
* Sends a message to a Channel (CORD-03 §3). * Sends a message to a Channel (CORD-03 §3), and returns it as a reader will see it.
* *
* The send *is* [PlaneKey.rumor] plus [PlaneKey.wrap], with nothing in between: the rumor carries * Throws when the Channel is hidden behind a key we were never granted — a Private Channel is
* the `channel`/`epoch` binding stamped from the Channel's own key, and the wrap is signed by that * listable without being writable.
* key's stream half, so a message cannot be built for a coordinate it will not verify at.
*
* The message is cached locally *before* it is published, so it is visible even if every relay is
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
* duplicating it.
*
* Returns the message as a reader will see it. Throws when the Channel is hidden behind a key we
* were never granted — a Private Channel is listable without being writable.
*/ */
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage { suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage =
publish(channelPlane(channelIdHex), ConcordKind.MESSAGE, content)
.toConcordMessage()
?: throw IllegalStateException("Concord: could not read back the message just sent")
/**
* Reacts to a message (CORD-03 §2.3). The `e` tag names the target's *rumor* id, and `p`/`k`
* carry its author and kind, which is what lets a reader group it without a lookup.
*/
suspend fun sendChannelReaction(
channelIdHex: String,
targetIdHex: String,
targetAuthorHex: String,
reaction: String,
) {
publish(
plane = channelPlane(channelIdHex),
kind = ConcordKind.REACTION,
content = reaction,
extraTags = listOf(
Tag.event(EventId.parse(targetIdHex)),
Tag.publicKey(PublicKey.parse(targetAuthorHex)),
Tag.custom(ConcordTag.K, listOf(ConcordKind.MESSAGE.toString())),
),
)
}
/**
* Replaces one of our own messages (CORD-03 §2.5). The `e` tag names the message being edited;
* the edit rides the same Channel plane, so it reaches exactly the readers the original did.
*/
suspend fun sendChannelEdit(channelIdHex: String, targetIdHex: String, content: String) {
publish(
plane = channelPlane(channelIdHex),
kind = ConcordKind.EDIT,
content = content,
extraTags = listOf(Tag.event(EventId.parse(targetIdHex))),
)
}
private fun channelPlane(channelIdHex: String): 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")
/**
* The whole write path: build the rumor from the Channel's own key, wrap it under that key's
* stream half, cache it locally, then publish to the Community's own relays.
*
* The rumor is cached *before* it is published, so it is visible even if every relay is
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
* duplicating it. The relays are always the Community's, never the app's defaults.
*/
private suspend fun publish(
plane: Plane,
kind: Int,
content: String,
extraTags: List<Tag> = emptyList(),
): UnsignedEvent {
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready") val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
val author = val author =
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") 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 rumor = plane.key.rumor( val rumor = plane.key.rumor(
author = author, author = author,
kind = ConcordKind.MESSAGE.toUShort(), kind = kind.toUShort(),
content = content, content = content,
createdAt = Clock.System.now(), createdAt = Clock.System.now(),
extraTags = extraTags,
) )
val wrap = plane.key.wrap(rumor, nostr.signer) val wrap = plane.key.wrap(rumor, nostr.signer)
@@ -349,10 +390,9 @@ class ConcordManager(private val nostr: Nostr) {
event = wrap, event = wrap,
target = SendEventTarget.to(relays), target = SendEventTarget.to(relays),
ackPolicy = AckPolicy.none() ackPolicy = AckPolicy.none()
).failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") } ).failed.forEach { (relay, reason) -> println("Concord: $relay refused a write: $reason") }
return rumor.toConcordMessage() return rumor
?: throw IllegalStateException("Concord: could not read back the message just sent")
} }
/** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */ /** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
@@ -565,7 +605,7 @@ class ConcordManager(private val nostr: Nostr) {
val relays = invite.relaySet(fallbackRelays) val relays = invite.relaySet(fallbackRelays)
val problems = val problems =
invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList() invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
return InvitePreview( return InvitePreview(
link = link, link = link,
invite = invite, invite = invite,
@@ -141,6 +141,12 @@ data class CommunityState(
val channels: List<ConcordChannel>, val channels: List<ConcordChannel>,
) )
/** One emoji and everyone who used it on a message (CORD-03 §2.3), grouped for display. */
data class ConcordReaction(
val emoji: String,
val authors: List<String>,
)
/** A Chat-plane message, already unwrapped and signature-checked. */ /** A Chat-plane message, already unwrapped and signature-checked. */
data class ConcordMessage( data class ConcordMessage(
val idHex: String, val idHex: String,
@@ -149,6 +155,9 @@ data class ConcordMessage(
val createdAt: Instant, val createdAt: Instant,
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */ /** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
val timestampMs: Long, val timestampMs: Long,
val reactions: List<ConcordReaction> = emptyList(),
/** True once the author's own Edit (kind `3302`) has replaced [content]. */
val edited: Boolean = false,
) )
/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */ /** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */
@@ -187,3 +196,51 @@ internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
timestampMs = concordTimestampMs(), timestampMs = concordTimestampMs(),
) )
} }
/**
* Projects one Channel's cached rumors into the timeline: its messages, with reactions and edits
* folded on top.
*
* Every one of these kinds names its target by the target's *rumor* id, never the outer wrap's —
* that id differs on every re-wrap (CORD-03 §2). A reaction for a message we no longer hold is
* dropped along with it. An Edit counts only from the message's own author: the `e` tag is a claim,
* and authorship is what the seal actually proves.
*/
internal fun List<UnsignedEvent>.toChannelMessages(): List<ConcordMessage> {
val messages = filter { it.isKind(ConcordKind.MESSAGE) }
.mapNotNull { it.toConcordMessage() }
.sortedBy { it.timestampMs }
val reactions = filter { it.isKind(ConcordKind.REACTION) }
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
.groupBy({ it.first }, { it.second })
.mapValues { (_, rumors) ->
rumors.groupBy { it.content() }
.map { (emoji, reactors) ->
ConcordReaction(
emoji = emoji.ifEmpty { "+" },
authors = reactors.map { it.author().toHex() }.distinct(),
)
}
}
val edits = filter { it.isKind(ConcordKind.EDIT) }
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
.groupBy({ it.first }, { it.second })
.mapValues { (_, candidates) -> candidates.maxBy { it.concordTimestampMs() } }
return messages.map { message ->
val edit = edits[message.idHex]?.takeIf { it.author().toHex() == message.author }
message.copy(
content = edit?.content() ?: message.content,
edited = edit != null,
reactions = reactions[message.idHex].orEmpty(),
)
}
}
private fun UnsignedEvent.isKind(expected: Int): Boolean = kind().asU16() == expected.toUShort()
/** The target of a reaction, edit or delete: the first `e` tag (CORD-03 §2.32.5). */
private fun UnsignedEvent.targetHex(): String? =
tags().toVec().firstOrNull { it.kind() == ConcordTag.E }?.content()
@@ -50,6 +50,21 @@ class ConcordRepository(
suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? = suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? =
attempt { concord.sendChannelMessage(channelIdHex, content) } attempt { concord.sendChannelMessage(channelIdHex, content) }
/** Reacts to one message; `targetAuthorHex` is the message's author, which rides the `p` tag. */
suspend fun sendReaction(
channelIdHex: String,
targetIdHex: String,
targetAuthorHex: String,
reaction: String,
) {
attempt { concord.sendChannelReaction(channelIdHex, targetIdHex, targetAuthorHex, reaction) }
}
/** Replaces one of our own messages with [content]. */
suspend fun editMessage(channelIdHex: String, targetIdHex: String, content: String) {
attempt { concord.sendChannelEdit(channelIdHex, targetIdHex, content) }
}
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */ /** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex) fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
@@ -53,7 +53,9 @@ class ChannelScreenViewModel(
reloadJob?.cancel() reloadJob?.cancel()
reloadJob = viewModelScope.launch { reloadJob = viewModelScope.launch {
val loaded = concordRepository.channelMessages(channelId) val loaded = concordRepository.channelMessages(channelId)
if (messages.map { it.idHex } != loaded.map { it.idHex }) { // The whole list, not just its ids: a reaction or an edit changes a message without
// changing which messages there are.
if (messages != loaded) {
messages.clear() messages.clear()
messages.addAll(loaded) messages.addAll(loaded)
} }
@@ -66,4 +68,16 @@ class ChannelScreenViewModel(
if (text.isBlank()) return if (text.isBlank()) return
viewModelScope.launch { concordRepository.sendMessage(channelId, text) } viewModelScope.launch { concordRepository.sendMessage(channelId, text) }
} }
fun sendReaction(message: ConcordMessage, reaction: String) {
viewModelScope.launch {
concordRepository.sendReaction(channelId, message.idHex, message.author, reaction)
}
}
/** Replaces one of our own messages; [targetIdHex] is the message being edited. */
fun editMessage(targetIdHex: String, text: String) {
if (text.isBlank()) return
viewModelScope.launch { concordRepository.editMessage(channelId, targetIdHex, text) }
}
} }