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` |
| 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 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
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("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
@@ -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.32.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.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? =
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) }
}
}