feat(cordn): show what you sent, and anchor the room at the bottom

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-24 02:17:48 +00:00
parent fd522877d9
commit 16bf18466d
4 changed files with 77 additions and 32 deletions
@@ -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() }
}
@@ -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,
),
)
@@ -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<Array<String>> = 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. */
@@ -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)
}