mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+51
-19
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+15
-5
@@ -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. */
|
||||
|
||||
+5
-5
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user