mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3767 from vitorpamplona/claude/corcord-read-only-communities-gu1x4k
Implement CORD-02 §9 community dissolution seal
This commit is contained in:
+18
@@ -251,11 +251,29 @@ fun ConcordChannelScreen(
|
||||
nav = nav,
|
||||
onMessageSent = { feedViewModel.feedState.sendToTop() },
|
||||
)
|
||||
} else if (channel.dissolved) {
|
||||
// CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is
|
||||
// gone (canPost() is false) and this replaces it so the seal is explained, not silent.
|
||||
ConcordDissolvedNotice()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-only notice shown where the composer would be once a community is dissolved (CORD-02 §9).
|
||||
* The tombstone seals the community: history stays readable, but no member may post again.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordDissolvedNotice() {
|
||||
Text(
|
||||
text = stringRes(R.string.concord_dissolved_read_only),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */
|
||||
private const val CONCORD_HISTORY_TARGET = 50
|
||||
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
<string name="concord_leave_message">Leave %1$s? It is removed from this account\'s list and stops syncing on your devices. The community is not notified and you are not removed from its member roster. Messages you can no longer decrypt may be unrecoverable, and you can only return with a new invite.</string>
|
||||
<string name="concord_leave_owner_warning">You created this community. Leaving does not delete it or hand it to anyone else, but it discards the owner key stored on your list — you would not be able to manage it again.</string>
|
||||
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</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_typing_one">%1$s is typing…</string>
|
||||
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
|
||||
<string name="concord_typing_many">Several people are typing…</string>
|
||||
|
||||
@@ -50,6 +50,8 @@ object ConcordChannelCommands {
|
||||
mapOf(
|
||||
"name" to state.metadata?.name,
|
||||
"description" to state.metadata?.description,
|
||||
// CORD-02 §9: once dissolved the community is sealed read-only (history only, no new posts).
|
||||
"dissolved" to state.dissolved,
|
||||
"icon" to state.metadata?.icon?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
|
||||
"banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
|
||||
"channels" to
|
||||
@@ -75,6 +77,11 @@ object ConcordChannelCommands {
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
// CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but
|
||||
// nothing new is honored, so refuse to post before we ever build/publish a wrap.
|
||||
if (foldState(ctx, sc).dissolved) {
|
||||
return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)")
|
||||
}
|
||||
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now())
|
||||
|
||||
+21
-2
@@ -78,6 +78,16 @@ class ConcordChannel(
|
||||
var membership: ConcordMembership = ConcordMembership.MEMBER
|
||||
private set
|
||||
|
||||
/**
|
||||
* True once the community has been dissolved by an owner-signed tombstone (CORD-02 §9). On sight
|
||||
* the client seals the Community read-only: held keys still open history, but nothing new is
|
||||
* honored, so no member — not even the owner — may post. Terminal and one-way (there is no
|
||||
* un-dissolve). The one carve-out (a member's delete of their own past message stays honored even
|
||||
* post-seal) runs through the note context menu, not the composer, so this gate never blocks it.
|
||||
*/
|
||||
var dissolved: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Per-relay backward-pagination cursors for this channel's history (CORD-03). The live
|
||||
* subscription only holds the recent tail the relay serves for the channel plane; older
|
||||
@@ -110,6 +120,7 @@ class ConcordChannel(
|
||||
val newCommunityIcon = state.metadata?.icon
|
||||
val newCommunityBanner = state.metadata?.banner
|
||||
val newMembership = ConcordMembership.of(state.authority, myPubKey)
|
||||
val newDissolved = state.dissolved
|
||||
|
||||
val changed =
|
||||
channelName != newChannelName ||
|
||||
@@ -118,7 +129,8 @@ class ConcordChannel(
|
||||
communityName != newCommunityName ||
|
||||
communityIcon != newCommunityIcon ||
|
||||
communityBanner != newCommunityBanner ||
|
||||
membership != newMembership
|
||||
membership != newMembership ||
|
||||
dissolved != newDissolved
|
||||
|
||||
channelName = newChannelName
|
||||
isVoice = newVoice
|
||||
@@ -128,6 +140,7 @@ class ConcordChannel(
|
||||
communityBanner = newCommunityBanner
|
||||
communityRelays = relays
|
||||
membership = newMembership
|
||||
dissolved = newDissolved
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -136,7 +149,13 @@ class ConcordChannel(
|
||||
|
||||
override fun toBestDisplayName(): String = channelName ?: channelId.channelId
|
||||
|
||||
fun canPost(): Boolean = membership.isMember()
|
||||
/**
|
||||
* Whether this account may post to the channel: it must hold a live standing in the community
|
||||
* ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9 —
|
||||
* a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even
|
||||
* after dissolution and does not go through this gate.
|
||||
*/
|
||||
fun canPost(): Boolean = membership.isMember() && !dissolved
|
||||
|
||||
// Synthetic note representing this channel in the Messages list before any
|
||||
// message has loaded (so a just-joined channel appears immediately). Mirrors
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.concord
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A member of a **dissolved** Concord community must not be able to post: CORD-02 §9 seals the
|
||||
* community read-only on an owner-signed tombstone. History stays readable (the composer gate is the
|
||||
* only thing this changes), so [ConcordChannel.canPost] must fold the dissolution flag in alongside
|
||||
* membership.
|
||||
*/
|
||||
class ConcordChannelDissolvedTest {
|
||||
private val owner = "0f".repeat(32)
|
||||
private val channelId = "ce".repeat(32)
|
||||
|
||||
private fun ed(
|
||||
kind: ControlEntityKind,
|
||||
eid: String,
|
||||
content: String,
|
||||
author: String = owner,
|
||||
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
|
||||
|
||||
private fun state(dissolved: Boolean): ConcordCommunityState {
|
||||
val editions =
|
||||
buildList {
|
||||
add(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}"""))
|
||||
if (dissolved) add(ed(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""))
|
||||
}
|
||||
return ConcordCommunityState.fold(editions, owner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveCommunityLetsTheOwnerPost() {
|
||||
val channel = ConcordChannel(ConcordChannelId(owner, channelId))
|
||||
channel.updateFrom(state(dissolved = false), emptySet(), owner)
|
||||
assertFalse(channel.dissolved)
|
||||
assertTrue(channel.canPost(), "a live community with a live standing must be postable")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dissolvedCommunitySealsPostingEvenForTheOwner() {
|
||||
val channel = ConcordChannel(ConcordChannelId(owner, channelId))
|
||||
// The owner is the most privileged member; if even they can't post, no one can.
|
||||
val changed = channel.updateFrom(state(dissolved = true), emptySet(), owner)
|
||||
assertTrue(changed, "flipping to dissolved is a displayed-field change (drives recomposition)")
|
||||
assertTrue(channel.dissolved)
|
||||
assertFalse(channel.canPost(), "a dissolved community is read-only (CORD-02 §9)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user