From 16bf18466df0037e85da645f3fe47b3a35a9399f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:17:48 +0000 Subject: [PATCH] feat(cordn): show what you sent, and anchor the room at the bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending in a cordn room left no trace in the room that sent it. Your own traffic comes back recognised by cursor and reported as Delivery.Echo, whose branch adds nothing, and no send path added anything either — so a message, a reaction, an edit, a deletion, a pin, an attachment and a voice note all appeared to do nothing at all. Deleting your own message looked like a no-op until somebody else's traffic refreshed the fold. - send/post now return CordnDeliveredMessage rather than CordnEnvelope, carrying the cursor the coordinator assigned. Guessing one would put your own message in the wrong place until the echo corrected it, since CordnGroupChatroom.ORDER sorts on it. - trySend adds the result to the room. add() is keyed on the envelope id and idempotent, so a later re-sync that does hand the message back cannot double it. - The three annotation paths that bypassed trySend — delete, pin toggle, and unpin from the ribbon — go through it now, so they report failures too. PinnedRibbon takes an onUnpin callback instead of a manager: a ribbon that posted for itself could neither report nor record. - Attachments and voice notes add their own message the same way. The list is reverseLayout with the rows reversed to match, so a room opens on its newest message and an arrival while you sit at the bottom keeps you there instead of pushing the conversation out of view. amy's `cordn send --json` gains a "cursor" field, which is where the message landed in the stream — previously discarded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../chats/cordnGroup/CordnGroupChatScreen.kt | 70 ++++++++++++++----- .../cli/commands/CordnGroupCommands.kt | 9 ++- .../commons/cordn/CordnGroupManager.kt | 20 ++++-- .../commons/cordn/CordnMessageActionsTest.kt | 10 +-- 4 files changed, 77 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index 99e3e7394d..8ebd5e9388 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -190,14 +190,22 @@ private fun CordnGroupChat( // One place for every outbound action, so none of them can go quiet again. // Returns false when it failed, which is what lets the composer put the // draft back rather than eat it. - suspend fun trySend(block: suspend (CordnGroupManager) -> Unit): Boolean { + suspend fun trySend(block: suspend (CordnGroupManager) -> CordnDeliveredMessage?): Boolean { val manager = manager() if (manager == null) { sendError = noSession return false } return try { - block(manager) + // Shown the moment the coordinator takes it, rather than when the + // echo comes back — which, for your own traffic, it never does as + // a message: the sync loop recognises it by cursor and reports it + // as Delivery.Echo, whose branch adds nothing to the room. So a + // sent message used to leave no trace in the room that sent it. + // + // add() is keyed on the envelope id and idempotent, so a later + // re-sync that does hand the message back cannot double it. + block(manager)?.let { room.add(it) } sendError = null true } catch (e: Exception) { @@ -242,13 +250,27 @@ private fun CordnGroupChat( // A pin is a claim about a message's importance, not a message, and // leaving it only in place means the thing someone pinned scrolls // away exactly like everything else. - PinnedRibbon(annotations, room, scope, ::manager) + PinnedRibbon(annotations, scope) { message -> + trySend { + it.post( + gid = room.gid, + pinTo = message.target(), + pinOp = CordnMessageReferences.PinOp.REMOVE, + ) + } + } LazyColumn( state = listState, + // Anchored at the bottom like every other chat: a room opens on + // its newest message, and an arrival while you sit at the + // bottom keeps you there instead of pushing the conversation up + // out of view. `messages` is oldest-first, so the rows are + // reversed to match. + reverseLayout = true, modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp), ) { - items(messages, key = { it.envelope.id }) { message -> + items(messages.asReversed(), key = { it.envelope.id }) { message -> CordnMessageRow( message = message, room = room, @@ -382,17 +404,23 @@ private fun CordnGroupChat( }, onDelete = { acting = null - scope.launch { manager()?.post(room.gid, deleteTo = message.target()) } + // Through trySend like the rest: a deletion is an annotation, + // and an annotation of your own comes back as an Echo too, so + // deleting your own message used to look like nothing had + // happened until someone else's traffic refreshed the fold. + scope.launch { trySend { it.post(room.gid, deleteTo = message.target()) } } }, onTogglePin = { val pinned = annotations.isPinned(message.envelope.id) acting = null scope.launch { - manager()?.post( - gid = room.gid, - pinTo = message.target(), - pinOp = if (pinned) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD, - ) + trySend { + it.post( + gid = room.gid, + pinTo = message.target(), + pinOp = if (pinned) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD, + ) + } } }, ) @@ -413,9 +441,11 @@ private fun CordnDeliveredMessage.target() = @Composable private fun PinnedRibbon( annotations: CordnAnnotationIndex, - room: CordnGroupChatroom, scope: CoroutineScope, - manager: () -> CordnGroupManager?, + // Hoisted rather than handed a manager: unpinning has to go through the + // caller's trySend so it reports a failure and lands in the room, and a + // ribbon that posted for itself could do neither. + onUnpin: suspend (CordnDeliveredMessage) -> Unit, ) { val pinned = annotations.pinnedIds().mapNotNull { annotations.byId[it] } if (pinned.isEmpty()) return @@ -441,11 +471,7 @@ private fun PinnedRibbon( ) TextButton(onClick = { scope.launch { - manager()?.post( - gid = room.gid, - pinTo = message.target(), - pinOp = CordnMessageReferences.PinOp.REMOVE, - ) + onUnpin(message) } }) { Text(stringRes(R.string.cordn_action_unpin), style = MaterialTheme.typography.labelSmall) @@ -761,7 +787,10 @@ private suspend fun sendAttachment( val bytes = withContext(Dispatchers.IO) { resolver.openInputStream(uri)?.use { it.readBytes() } } ?: return val tag = CordnMediaService(accountViewModel.account).upload(bytes, mime, name, context) ?: return - session.manager.send(room.gid, content = "", tags = arrayOf(tag)) + // Into the room as well, for the same reason every other send is: an + // attachment of your own echoes back as an Echo and would otherwise be + // invisible to the person who sent it. + room.add(session.manager.send(room.gid, content = "", tags = arrayOf(tag))) } /** @@ -928,7 +957,10 @@ private suspend fun sendVoiceNote( val tag = CordnMediaService(accountViewModel.account) .upload(bytes, recording.mimeType, recording.file.name, context) ?: return - session.manager.send(room.gid, content = "", tags = arrayOf(tag)) + // Into the room as well, for the same reason every other send is: an + // attachment of your own echoes back as an Echo and would otherwise be + // invisible to the person who sent it. + room.add(session.manager.send(room.gid, content = "", tags = arrayOf(tag))) } finally { withContext(Dispatchers.IO) { recording.file.delete() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt index a0dc614122..7c44488126 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt @@ -545,9 +545,12 @@ internal object CordnGroupCommands { // The envelope id is the message's identity (spec/02.md §7) // — the cursor a fetch reports is not, so a script that // needs to refer to this message later must use this. - "id" to envelope.id, - "kind" to envelope.kind, - "created_at" to envelope.createdAt, + "id" to envelope.envelope.id, + "kind" to envelope.envelope.kind, + "created_at" to envelope.envelope.createdAt, + // The coordinator's cursor for this post, so a script can + // see where in the stream its own message landed. + "cursor" to envelope.cursor, "epoch" to scope.manager.group(gid)?.epoch, ), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt index 85dcd46147..5e3a2e6300 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest import com.vitorpamplona.quartz.cordn.spec00Coordinator.KeyPackagePublication import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences import com.vitorpamplona.quartz.cordn.spec02Envelopes.ReceivedMessage @@ -536,7 +537,7 @@ class CordnGroupManager( deleteTo: CordnMessageReferences.Target? = null, pinTo: CordnMessageReferences.Target? = null, pinOp: CordnMessageReferences.PinOp = CordnMessageReferences.PinOp.ADD, - ): CordnEnvelope { + ): CordnDeliveredMessage { editTo?.let { require(it.pubKey == accountPubKey) { "only a message's author can edit it" } } deleteTo?.let { require(it.pubKey == accountPubKey) { "only a message's author can delete it" } } @@ -553,13 +554,22 @@ class CordnGroupManager( return send(gid, outbound.content, outbound.kind, outbound.tags) } - /** Sends [content] to [gid] as a cordn application message. */ + /** + * Sends [content] to [gid] as a cordn application message. + * + * Returns the message as the room will hold it, cursor included. The + * cursor is the coordinator's, taken from the post's own response, so a + * caller that shows the message immediately places it in the same order + * the echo would have — [CordnGroupChatroom.ORDER] sorts on it, and a + * guessed one would put your own message in the wrong place until the + * echo corrected it. + */ suspend fun send( gid: String, content: String, kind: Int = CHAT_KIND, tags: Array> = emptyArray(), - ): CordnEnvelope { + ): CordnDeliveredMessage { val group = requireGroup(gid) val envelope = CordnEnvelope.build( @@ -570,9 +580,9 @@ class CordnGroupManager( content = content, ) val sealed = CordnApplicationMessage.seal(group, accountPubKey, envelope) - call { sync.postMessage(gid, sealed) } + val posted = call { sync.postMessage(gid, sealed) } persist(gid) - return envelope + return CordnDeliveredMessage(envelope, posted.cursor) } /** Drains history for every group this manager holds. */ diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt index 6adb8a55c8..4148e8d558 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt @@ -75,7 +75,7 @@ class CordnMessageActionsTest { val manager = manager(FakeCoordinator(callerPubKey = alice)) manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) - val sent = manager.post(gid, "in reply", replyTo = target(bob)) + val sent = manager.post(gid, "in reply", replyTo = target(bob)).envelope assertEquals(CordnMessageKinds.THREAD_REPLY, sent.kind) assertEquals("in reply", sent.content) @@ -88,7 +88,7 @@ class CordnMessageActionsTest { val manager = manager(FakeCoordinator(callerPubKey = alice)) manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) - val sent = manager.post(gid, "👍", reactionTo = target(bob)) + val sent = manager.post(gid, "👍", reactionTo = target(bob)).envelope assertEquals(CordnMessageKinds.REACTION, sent.kind) assertEquals("👍", sent.content) @@ -126,8 +126,8 @@ class CordnMessageActionsTest { val manager = manager(FakeCoordinator(callerPubKey = alice)) manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) - assertEquals(CordnMessageKinds.EDIT, manager.post(gid, "fixed", editTo = target(alice)).kind) - assertEquals(CordnMessageKinds.DELETION, manager.post(gid, deleteTo = target(alice)).kind) + assertEquals(CordnMessageKinds.EDIT, manager.post(gid, "fixed", editTo = target(alice)).envelope.kind) + assertEquals(CordnMessageKinds.DELETION, manager.post(gid, deleteTo = target(alice)).envelope.kind) } @Test @@ -139,7 +139,7 @@ class CordnMessageActionsTest { val manager = manager(FakeCoordinator(callerPubKey = alice)) manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) - val sent = manager.post(gid, pinTo = target(bob)) + val sent = manager.post(gid, pinTo = target(bob)).envelope assertEquals(CordnMessageKinds.PIN, sent.kind) }