feat(concord): let the owner dissolve a community

`dissolveConcordCommunity` existed with no UI caller, so an owner could
only dissolve a community from amy. Adds an owner-only "Dissolve
community" item beside Leave, with an irreversible-action confirmation.
Verified: amy and a live member see the community read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-30 09:35:21 -04:00
co-authored by Claude Opus 5.5
parent e6ee629c04
commit c994027863
3 changed files with 71 additions and 0 deletions
@@ -615,6 +615,11 @@
<string name="concord_timer_notice_off">%1$s turned off disappearing messages</string>
<string name="concord_timer_active">Messages disappear after %1$s</string>
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
<string name="concord_dissolve_community">Dissolve community</string>
<string name="concord_dissolve_title">Dissolve %1$s?</string>
<string name="concord_dissolve_message">This ends the community for everyone. It becomes read-only: members keep its history, but nobody can post, change it, or invite anyone again. This can't be undone.</string>
<string name="concord_dissolve_confirm">Dissolve</string>
<string name="concord_dissolve_failed">The community was not dissolved: only its owner can, from an account that can sign.</string>
<string name="concord_private_channel_no_key">This is a private channel and you don't hold its key, so you can't read it or post here.</string>
<string name="concord_private_channel_cut">You no longer have access to this private channel: its key was rotated without you, so you can't read new messages or post here.</string>
<string name="concord_banned_notice">You're banned from this community, so you can't post here and your messages are hidden from everyone.</string>
@@ -95,6 +95,10 @@ import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key
import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action
import com.vitorpamplona.amethyst.commons.resources.concord_dissolve_community
import com.vitorpamplona.amethyst.commons.resources.concord_dissolve_confirm
import com.vitorpamplona.amethyst.commons.resources.concord_dissolve_message
import com.vitorpamplona.amethyst.commons.resources.concord_dissolve_title
import com.vitorpamplona.amethyst.commons.resources.concord_edit_title
import com.vitorpamplona.amethyst.commons.resources.concord_invite_action
import com.vitorpamplona.amethyst.commons.resources.concord_invite_links_action
@@ -193,6 +197,7 @@ fun ConcordChannelListScreen(
// Read once here (it is @Composable) so the post-leave navigation can use it from a callback.
val canPop = nav.canPop()
var showLeave by remember { mutableStateOf(false) }
var showDissolve by remember { mutableStateOf(false) }
var showDirectInvite by remember { mutableStateOf(false) }
if (showDirectInvite) {
@@ -217,6 +222,18 @@ fun ConcordChannelListScreen(
)
}
if (showDissolve) {
ConcordDissolveDialog(
communityName = communityName,
onDismiss = { showDissolve = false },
onConfirm = {
showDissolve = false
// Stay here: the dissolved community stays readable, and this screen shows it read-only.
accountViewModel.dissolveConcordCommunity(communityId)
},
)
}
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
@@ -416,6 +433,21 @@ fun ConcordChannelListScreen(
showLeave = true
},
)
// Owner only, like the verifiers: anyone else's tombstone is ignored (CORD-02 §9).
if (isOwner && state?.dissolved != true) {
DropdownMenuItem(
text = {
Text(
stringRes(Res.string.concord_dissolve_community),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuOpen = false
showDissolve = true
},
)
}
}
},
)
@@ -728,6 +760,30 @@ fun ConcordLeaveDialog(
)
}
/** Confirms dissolving a community (CORD-02 §9): irreversible, so the confirm button is the error color. */
@Composable
fun ConcordDissolveDialog(
communityName: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.concord_dissolve_title, communityName)) },
text = { Text(stringRes(Res.string.concord_dissolve_message)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringRes(Res.string.concord_dissolve_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
)
}
/** A pending channel create ([channelIdHex] null) or rename target. */
private data class ConcordChannelEditor(
val channelIdHex: String?,
@@ -78,6 +78,8 @@ import com.vitorpamplona.amethyst.commons.resources.cashu_successful_redemption_
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key_done
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key_failed
import com.vitorpamplona.amethyst.commons.resources.concord_dissolve_community
import com.vitorpamplona.amethyst.commons.resources.concord_dissolve_failed
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_message
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_message_unnamed
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_title
@@ -652,6 +654,14 @@ class AccountViewModel(
toastManager.toast(Res.string.concord_pin_failed_title, message)
}
/** Dissolve [communityId] for good (CORD-02 §9, owner only); a refusal surfaces as a toast. */
fun dissolveConcordCommunity(communityId: String) =
launchSigner {
if (!account.concord.dissolveConcordCommunity(communityId)) {
toastManager.toast(Res.string.concord_dissolve_community, Res.string.concord_dissolve_failed)
}
}
/**
* Rotate Private Channel [channelIdHex]'s key (CORD-06). The rotation publishes a chunk per
* member and can take a while, so both outcomes are toasted: a silent menu item left the