diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index d2a27359a4..d147f43a6d 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -615,6 +615,11 @@ %1$s turned off disappearing messages Messages disappear after %1$s This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted. + Dissolve community + Dissolve %1$s? + 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. + Dissolve + The community was not dissolved: only its owner can, from an account that can sign. This is a private channel and you don't hold its key, so you can't read it or post here. 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. You're banned from this community, so you can't post here and your messages are hidden from everyone. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d1d4113987..5c01e9bae8 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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?, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index 2483236e25..c0c776ec9b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -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