diff --git a/PLAN.md b/PLAN.md index 51838b4..e9ad0d0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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`, 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. --- diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt index 8c7f56a..2a9f09e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt @@ -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, +fun MessageReactions( + emojis: List, + 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, ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index 2a84d40..2965514 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -650,7 +650,7 @@ private fun ReplyPreview( } @Composable -private fun ReactionToolbar( +fun ReactionToolbar( onReaction: (String) -> Unit ) { val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉") diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt index 96278f4..e732054 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/ChannelScreen.kt @@ -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(null) } + + /** The message an Edit is being written for, if any. Its text lives in [text]. */ + var editing by remember { mutableStateOf(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") } + } + } + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt index 416b2d8..49f2b42 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt @@ -222,6 +222,9 @@ object ConcordTag { /** `["q", "", "", ""]` — NIP-C7 inline quote. */ const val QUOTE = "q" + /** `["e", ""]` — the message a reaction, edit or delete targets (CORD-03 §2.3–2.5). */ + const val E = "e" + /** `["vsk", ""]` — Control edition entity type. */ const val VSK = "vsk" @@ -249,6 +252,6 @@ object ConcordTag { /** `["invite", "", "