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) }