diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt
index 831d3b1e07..7d4efc6ded 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -43,7 +44,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -72,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -98,6 +102,11 @@ fun MarmotGroupChatView(
WatchLifecycleAndUpdateModel(feedViewModel)
+ val chatroom =
+ remember(nostrGroupId) {
+ accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
+ }
+
val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel")
newMessageModel.init(accountViewModel)
newMessageModel.load(nostrGroupId)
@@ -157,15 +166,27 @@ fun MarmotGroupChatView(
Spacer(modifier = DoubleVertSpacer)
- MarmotGroupMessageComposer(
- nostrGroupId = nostrGroupId,
- newMessageModel = newMessageModel,
- accountViewModel = accountViewModel,
- nav = nav,
- onMessageSent = {
- feedViewModel.feedState.sendToTop()
- },
- )
+ // A durable outbound gate means the group takes no new work: an
+ // unresolved disband request, a SelfRemove already sent, a realized
+ // removal. Sending would throw behind it, so the composer is replaced
+ // by the reason rather than left there to fail on tap — the history
+ // stays readable either way, which is the point of a gate that is not
+ // a terminal state.
+ val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle()
+ val gate = outboundGate
+ if (gate != null) {
+ MarmotGroupClosedComposer(gate)
+ } else {
+ MarmotGroupMessageComposer(
+ nostrGroupId = nostrGroupId,
+ newMessageModel = newMessageModel,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ onMessageSent = {
+ feedViewModel.feedState.sendToTop()
+ },
+ )
+ }
}
}
@@ -349,3 +370,32 @@ private fun MarmotGroupFileUploadDialog(
isNip17 = false,
)
}
+
+/**
+ * Stands in for the composer when an outbound gate is up.
+ *
+ * Deliberately a statement rather than a disabled text field: a greyed-out
+ * input still invites typing, and the three reasons are not the same — one is
+ * waiting on the group, one on a commit, and one is over. The group's history
+ * stays on screen above it.
+ */
+@Composable
+private fun MarmotGroupClosedComposer(gate: LocalOutboundGate) {
+ val message =
+ when (gate) {
+ LocalOutboundGate.DISBANDING -> stringRes(R.string.marmot_group_composer_disbanding)
+ LocalOutboundGate.LEAVING -> stringRes(R.string.marmot_group_composer_leaving)
+ LocalOutboundGate.REMOVED -> stringRes(R.string.marmot_group_composer_removed)
+ }
+ Row(
+ modifier = EditFieldModifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.placeholderText,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 083066de6a..c84993292f 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -2701,6 +2701,9 @@
Could not switch this group to encrypted attachments: %1$s
Group disbanded
Ending the group. It finishes once the group agrees.
+ This group is being ended. You can still read it.
+ You are leaving this group.
+ You are no longer a member of this group.
Could not disband the group: %1$s
Adding %1$s…
Failed to add %1$s: %2$s
diff --git a/cli/tests/README.md b/cli/tests/README.md
index c3ae7cfb59..cf2b314a64 100644
--- a/cli/tests/README.md
+++ b/cli/tests/README.md
@@ -111,6 +111,16 @@ The Marmot harnesses come in two flavours, same scenarios:
and uniffi surface (the apps call it) but has no `wn groups` verb, so
test 29 runs one way only.
+ **The daemon is not a way around this**, which is worth stating because it
+ is the obvious next idea. `wnd`'s socket protocol
+ (`crates/cli/src/daemon/protocol.rs`) carries `Ping`, `Status`, `Shutdown`,
+ four `*Subscribe` variants, and `Execute { cli: Box }` — and that last
+ one takes the same clap command tree `wn` parses. The daemon is a persistent
+ host for the CLI's verbs, not a richer RPC, so a verb missing from `Cli` is
+ unreachable through the socket too. Closing these three needs either a verb
+ upstream in MDK or a driver linked against `marmot-uniffi`/`marmot-c`; both
+ are out of scope for a harness that deliberately builds MDK unpatched.
+
A third, slimmer harness covers the NIP-17 DM surface:
- **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt
index b3cb67e24e..e5928616a3 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt
@@ -2539,6 +2539,10 @@ class MarmotManager(
chatroom.isCurrentProfile.value = view.isCurrentProfile
chatroom.hasEncryptedMediaPolicy.value = encryptedMediaPolicy(nostrGroupId) != null
}
+ // Read every sync, because a gate is raised and cleared by protocol
+ // events the UI never sees directly — a disband request resolving, a
+ // removal being realized.
+ chatroom.outboundGate.value = publishGate.outboundGateNow(nostrGroupId)
val previousCount = chatroom.members.value.size
val members = memberPubkeys(nostrGroupId)
chatroom.members.value = members
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt
index 573a1c3b29..05aa779b29 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
+import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow
@@ -98,6 +99,18 @@ class MarmotGroupChatroom(
*/
var hasEncryptedMediaPolicy = MutableStateFlow(false)
+ /**
+ * Why this group takes no new outbound work, or null when it does.
+ *
+ * A durable outbound gate is not a lifecycle state: the member is still in
+ * the tree and the group is not terminal, but nothing new may be sent —
+ * an unresolved disband request, a SelfRemove already sent, a realized
+ * removal. A front end needs it separately from [isCurrentProfile] and the
+ * lifecycle so it can DISABLE the composer with a reason rather than let a
+ * send throw and surface as an error after the fact.
+ */
+ var outboundGate = MutableStateFlow(null)
+
var adminPubkeys = MutableStateFlow>(emptyList())
var relays = MutableStateFlow>(emptyList())
var memberCount = MutableStateFlow(0)
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt
index b1b651f63b..4ddf1943f6 100644
--- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt
@@ -20,10 +20,12 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
+import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.marmot.protocolCore.InMemoryPublishObligationStore
+import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
@@ -31,6 +33,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
+import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
@@ -237,6 +240,26 @@ class MarmotDisbandTest {
Unit
}
+ @Test
+ fun `the gate reaches the front end's group state`() =
+ runBlocking {
+ // The UI cannot ask a suspending publish gate from the synchronous
+ // path that refreshes a conversation, so the gate is mirrored onto
+ // the chatroom. If that mirror is missing, the composer stays
+ // enabled on a group that refuses every send and the user finds out
+ // by tapping.
+ val f = Fixture()
+ f.createCurrentProfile()
+ val chatroom = MarmotGroupChatroom(nostrGroupId)
+
+ f.manager.syncMetadataTo(nostrGroupId, chatroom)
+ assertNull(chatroom.outboundGate.value, "a live group has no gate")
+
+ f.manager.disbandGroup(nostrGroupId)
+ f.manager.syncMetadataTo(nostrGroupId, chatroom)
+ assertEquals(LocalOutboundGate.DISBANDING, chatroom.outboundGate.value)
+ }
+
@Test
fun `a pending disband request outlives a restart`() =
runBlocking {
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt
index c74b9470af..f1ed9c8d75 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt
@@ -265,6 +265,27 @@ class MarmotConvergenceEngine(
ctx.lifecycle = pass.lifecycleWhileRunning(ctx.lifecycle)
}
+ /**
+ * End the recovery a settled pass was running.
+ *
+ * `Recovering` describes a pass IN FLIGHT — a fork being resolved, or a
+ * disband candidate waiting for selection. Once the pass settles the group
+ * has a selected branch again and is `Stable`, so leaving the state behind
+ * makes every later reader believe a recovery is still running: the group
+ * reads as recovering forever, and anything that gates on the reported
+ * lifecycle (rather than on the publish gate, which is the authority for
+ * whether a commit may be prepared) silently stops working.
+ *
+ * Only `Recovering` is cleared. `Unrecoverable` is not a pass outcome —
+ * it means this client cannot safely apply more traffic and is cleared by a
+ * verified repair — and `Disbanded` is absorbing.
+ */
+ private fun endRecovery(ctx: GroupContext) {
+ if (ctx.lifecycle == GroupLifecycleState.RECOVERING) {
+ ctx.lifecycle = GroupLifecycleState.STABLE
+ }
+ }
+
/**
* Move a group to `Disbanded` once its lifecycle component says so.
*
@@ -562,6 +583,7 @@ class MarmotConvergenceEngine(
trimCandidates(ctx)
ctx.divergent.clear()
ctx.pass = null
+ endRecovery(ctx)
terminalizeIfDisbanded(groupId, ctx)
val epoch = groupManager.getGroup(groupId)?.epoch ?: inputs.tipEpoch
ConvergenceResolution(
@@ -599,6 +621,7 @@ class MarmotConvergenceEngine(
pass.freeze()
ctx.pass = null
+ endRecovery(ctx)
terminalizeIfDisbanded(groupId, ctx)
ConvergenceResolution(
groupId = groupId,
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt
index 257b4be716..91bd6b769d 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -218,6 +220,24 @@ class MarmotPublishGate(
private val gates = mutableMapOf()
private val lifecycles = mutableMapOf()
+ /**
+ * An immutable copy of [gates], republished on every change.
+ *
+ * The map itself is mutable state guarded by [mutex], so it cannot be read
+ * from a non-suspending caller without a data race. A front end needs the
+ * gate on the synchronous path that refreshes a conversation's state — and
+ * making that path suspend would push `suspend` up through every caller for
+ * one flag — so the authority stays behind the lock and this is what
+ * everyone else reads.
+ */
+ private val gateSnapshot = MutableStateFlow