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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -229,7 +229,8 @@ fun ChatMessage(
|
||||
|
||||
if (model.reactions.isNotEmpty()) {
|
||||
MessageReactions(
|
||||
reactions = model.reactions,
|
||||
emojis = model.reactions.map { it.emoji },
|
||||
total = model.reactions.sumOf { it.authors.size },
|
||||
modifier = Modifier.offset(y = 12.dp)
|
||||
)
|
||||
}
|
||||
@@ -247,13 +248,14 @@ fun ChatMessage(
|
||||
}
|
||||
}
|
||||
|
||||
/** The reaction chips under a bubble, shared by DMs and Channels. */
|
||||
@Composable
|
||||
private fun MessageReactions(
|
||||
reactions: List<ReactionGroup>,
|
||||
fun MessageReactions(
|
||||
emojis: List<String>,
|
||||
total: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val totalCount = reactions.sumOf { it.authors.size }
|
||||
val displayEmojis = reactions.take(3).map { it.emoji }
|
||||
val displayEmojis = emojis.take(3)
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
@@ -274,7 +276,7 @@ private fun MessageReactions(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalCount > 2) {
|
||||
if (total > 2) {
|
||||
Surface(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
@@ -282,7 +284,7 @@ private fun MessageReactions(
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = totalCount.toString(),
|
||||
text = total.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
@@ -650,7 +650,7 @@ private fun ReplyPreview(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReactionToolbar(
|
||||
fun ReactionToolbar(
|
||||
onReaction: (String) -> Unit
|
||||
) {
|
||||
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
|
||||
|
||||
+87
-3
@@ -1,5 +1,7 @@
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -28,6 +30,7 @@ import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -56,6 +59,8 @@ import su.reya.coop.dayLabel
|
||||
import su.reya.coop.sanitizeName
|
||||
import su.reya.coop.screens.chat.ChatInput
|
||||
import su.reya.coop.screens.chat.DateSeparator
|
||||
import su.reya.coop.screens.chat.MessageReactions
|
||||
import su.reya.coop.screens.chat.ReactionToolbar
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.timeLabel
|
||||
@@ -81,6 +86,12 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
|
||||
|
||||
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 {
|
||||
derivedStateOf {
|
||||
viewModel.messages
|
||||
@@ -193,6 +204,18 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
|
||||
ChannelMessageRow(
|
||||
message = message,
|
||||
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) }
|
||||
@@ -211,13 +234,33 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
} 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
|
||||
// that does nothing would be worse than its absence.
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(text)
|
||||
val target = editing
|
||||
if (target == null) viewModel.sendMessage(text)
|
||||
else viewModel.editMessage(target.idHex, text)
|
||||
editing = null
|
||||
text = ""
|
||||
},
|
||||
)
|
||||
@@ -229,7 +272,15 @@ fun ChannelScreen(viewModel: ChannelScreenViewModel) {
|
||||
}
|
||||
|
||||
@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 name = profile?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
||||
?: pubkey?.short()
|
||||
@@ -242,7 +293,16 @@ private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) {
|
||||
) {
|
||||
Avatar(picture = profile?.picture, description = name, size = 36.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) {
|
||||
Text(
|
||||
text = if (isMine) "$name (you)" else name,
|
||||
@@ -258,12 +318,36 @@ private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) {
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
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 = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
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. */
|
||||
const val QUOTE = "q"
|
||||
|
||||
/** `["e", "<rumor id>"]` — the message a reaction, edit or delete targets (CORD-03 §2.3–2.5). */
|
||||
const val E = "e"
|
||||
|
||||
/** `["vsk", "<n>"]` — Control edition entity type. */
|
||||
const val VSK = "vsk"
|
||||
|
||||
@@ -249,6 +252,6 @@ object ConcordTag {
|
||||
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.PublicKey
|
||||
@@ -283,14 +284,9 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
?: CommunityState(membership, null, emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
|
||||
* keeps it current.
|
||||
*/
|
||||
/** A Channel's messages, oldest first, with reactions and edits folded on top. */
|
||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
||||
store?.cachedRumors(channelIdHex).orEmpty()
|
||||
.mapNotNull { it.toConcordMessage() }
|
||||
.sortedBy { it.timestampMs }
|
||||
store?.cachedRumors(channelIdHex).orEmpty().toChannelMessages()
|
||||
|
||||
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
|
||||
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
|
||||
* the `channel`/`epoch` binding stamped from the Channel's own key, and the wrap is signed by that
|
||||
* 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.
|
||||
* 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 author =
|
||||
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val plane = planes.values.firstOrNull {
|
||||
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
||||
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
||||
|
||||
|
||||
val rumor = plane.key.rumor(
|
||||
author = author,
|
||||
kind = ConcordKind.MESSAGE.toUShort(),
|
||||
kind = kind.toUShort(),
|
||||
content = content,
|
||||
createdAt = Clock.System.now(),
|
||||
extraTags = extraTags,
|
||||
)
|
||||
val wrap = plane.key.wrap(rumor, nostr.signer)
|
||||
|
||||
@@ -349,10 +390,9 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
event = wrap,
|
||||
target = SendEventTarget.to(relays),
|
||||
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()
|
||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
||||
return rumor
|
||||
}
|
||||
|
||||
/** 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 problems =
|
||||
invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
||||
|
||||
|
||||
return InvitePreview(
|
||||
link = link,
|
||||
invite = invite,
|
||||
|
||||
@@ -141,6 +141,12 @@ data class CommunityState(
|
||||
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. */
|
||||
data class ConcordMessage(
|
||||
val idHex: String,
|
||||
@@ -149,6 +155,9 @@ data class ConcordMessage(
|
||||
val createdAt: Instant,
|
||||
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
|
||||
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). */
|
||||
@@ -187,3 +196,51 @@ internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
|
||||
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.3–2.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? =
|
||||
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. */
|
||||
fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ class ChannelScreenViewModel(
|
||||
reloadJob?.cancel()
|
||||
reloadJob = viewModelScope.launch {
|
||||
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.addAll(loaded)
|
||||
}
|
||||
@@ -66,4 +68,16 @@ class ChannelScreenViewModel(
|
||||
if (text.isBlank()) return
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user