mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #4265 from vitorpamplona/fix/concord-interop
fix(concord): cross-client interop with Armada and Accordion
This commit is contained in:
+9
-1
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.back
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_create_action
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_create_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_create_relays
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_create_relays_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_create_title
|
||||
@@ -136,7 +137,14 @@ fun ConcordCreateScreen(
|
||||
// Always re-enable — a thrown create would otherwise strand the button.
|
||||
working = false
|
||||
}
|
||||
if (communityId != null) nav.newStack(Route.ConcordServer(communityId))
|
||||
// Replace this form with the new community, as the Marmot and relay-group creators
|
||||
// do. newStack only popped up to the community route itself, which was not on the
|
||||
// stack, so Back from the new community reopened a filled-in create form.
|
||||
if (communityId != null) {
|
||||
nav.popUpTo(Route.ConcordServer(communityId), Route.ConcordCreate::class)
|
||||
} else {
|
||||
accountViewModel.toastManager.toast(Res.string.concord_create_title, Res.string.concord_create_failed)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = name.value.isNotBlank() && !working,
|
||||
|
||||
+9
-7
@@ -1036,15 +1036,17 @@ private fun ConcordServerRoomCompose(
|
||||
// Community name/icon from the folded Control Plane (bumped via the session revision).
|
||||
val revision by accountViewModel.account.concordSessions.revision
|
||||
.collectAsStateWithLifecycle()
|
||||
val metadata =
|
||||
val session =
|
||||
remember(row.communityId, revision) {
|
||||
accountViewModel.account.concordSessions
|
||||
.sessionFor(row.communityId)
|
||||
?.state
|
||||
?.value
|
||||
?.metadata
|
||||
accountViewModel.account.concordSessions.sessionFor(row.communityId)
|
||||
}
|
||||
val name = metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.concord_home_title)
|
||||
val metadata = remember(session, revision) { session?.state?.value?.metadata }
|
||||
// Before the Control Plane folds there is no metadata; the name the community was joined under
|
||||
// still tells the user which one it is.
|
||||
val name =
|
||||
metadata?.name?.takeIf { it.isNotBlank() }
|
||||
?: session?.entry?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringRes(Res.string.concord_home_title)
|
||||
|
||||
val author = row.newestMessage?.author
|
||||
val noteEvent = row.newestMessage?.event
|
||||
|
||||
+1
-1
@@ -678,7 +678,7 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
|
||||
| `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). |
|
||||
| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
|
||||
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. |
|
||||
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). |
|
||||
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). Banned members' messages are left out and counted in `hidden_banned`. |
|
||||
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. |
|
||||
| `amy concord invite COMMUNITY --to USER [--expires-in SECS]` | Send a Direct Invite (CORD-05 §6): the bundle giftwrapped as standard NIP-59 (kind-3313 rumor, `k=3313` wrap tag, NIP-40 expiration when `--expires-in` is set) to USER (npub, hex, nprofile or NIP-05) on their kind-10050 relays, else NIP-65 read relays, else the stock set. Carries only the private-channel keys USER's roles grant; refused for a banned recipient. No registry entry, never flips the community Public, cannot be revoked. |
|
||||
| `amy concord invites` | List Direct Invites waiting for this account (sender, community name/icon, expired, catch-up). Read-only: nothing joins or contacts the community's relays. Communities you already hold are hidden unless the invite carries new channel keys on the same base (a catch-up). |
|
||||
|
||||
+5
-1
@@ -158,13 +158,17 @@ object ConcordChannelCommands {
|
||||
// The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates.
|
||||
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val msgs = ConcordActions.channelMessages(wraps, channel, channelId, epoch).takeLast(limit)
|
||||
// A banned member's messages are hidden, as every client shows the channel (CORD-04 §4);
|
||||
// the count of what was hidden stays in the output so interop checks can see it arrived.
|
||||
val (banned, visible) = ConcordActions.channelMessages(wraps, channel, channelId, epoch).partition { state.authority.isBanned(it.author) }
|
||||
val msgs = visible.takeLast(limit)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"channel" to channelId,
|
||||
"epoch" to epoch,
|
||||
"plane" to channel.publicKeyHex,
|
||||
"count" to msgs.size,
|
||||
"hidden_banned" to banned.size,
|
||||
"messages" to msgs.map { mapOf("event_id" to it.id, "author" to it.author, "content" to it.content, "created_at" to it.createdAt) },
|
||||
),
|
||||
)
|
||||
|
||||
+12
-6
@@ -258,16 +258,22 @@ object ConcordSubscriptionPlanner {
|
||||
accountPubKey: HexKey? = null,
|
||||
stateOf: (ConcordCommunityListEntry) -> ConcordCommunityState?,
|
||||
): List<RelayBasedFilter> {
|
||||
val controlSubs = controlPlaneSubs(entries)
|
||||
// A community whose Control Plane hasn't folded yet (just joined, or its editions never
|
||||
// arrived) is fetched without the relay's `since`: that cursor was set by the OTHER
|
||||
// communities on the relay, and applying it here skips every edition older than it — the
|
||||
// genesis included — so the community showed no channels until a restart dropped the cursor.
|
||||
val (folded, unfolded) = entries.partition { stateOf(it) != null }
|
||||
|
||||
val otherSubs = ArrayList<ConcordPlaneSub>()
|
||||
otherSubs += auxiliaryPlaneSubs(entries)
|
||||
for (entry in entries) {
|
||||
val state = stateOf(entry) ?: continue
|
||||
otherSubs += channelPlaneSubs(entry, state)
|
||||
otherSubs += auxiliaryPlaneSubs(folded)
|
||||
for (entry in folded) {
|
||||
otherSubs += channelPlaneSubs(entry, stateOf(entry) ?: continue)
|
||||
}
|
||||
|
||||
return relayBasedFilters(controlSubs, since, accountPubKey).orEmpty() + relayBasedFilters(otherSubs, since, accountPubKey).orEmpty()
|
||||
return relayBasedFilters(controlPlaneSubs(folded), since, accountPubKey).orEmpty() +
|
||||
relayBasedFilters(otherSubs, since, accountPubKey).orEmpty() +
|
||||
relayBasedFilters(controlPlaneSubs(unfolded), null, accountPubKey).orEmpty() +
|
||||
relayBasedFilters(auxiliaryPlaneSubs(unfolded), null, accountPubKey).orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-4
@@ -212,7 +212,11 @@ class ChatroomListKnownFeedFilter(
|
||||
when (account.settings.concordViewMode.value) {
|
||||
ConcordViewMode.INLINE ->
|
||||
account.concordSessions.sessions().flatMap { session ->
|
||||
val state = session.state.value ?: return@flatMap emptyList<Note>()
|
||||
// Not folded yet (just joined, or its Control Plane never arrived): no
|
||||
// channels to list, so show the community itself. Skipping it made a
|
||||
// community whose genesis is missing invisible everywhere, with no way in
|
||||
// and no way to leave it.
|
||||
val state = session.state.value ?: return@flatMap listOf<Note>(ConcordServerRoomNote(session.entry.id, null))
|
||||
state.channels.keys.map { channelIdHex ->
|
||||
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex))
|
||||
channel.newestConcordNote(account) ?: channel.placeholderNote()
|
||||
@@ -221,10 +225,14 @@ class ChatroomListKnownFeedFilter(
|
||||
|
||||
ConcordViewMode.GROUPED ->
|
||||
// One row per joined community, carrying the newest message across ALL its channels.
|
||||
account.concordSessions.sessions().mapNotNull { session ->
|
||||
val state = session.state.value ?: return@mapNotNull null
|
||||
account.concordSessions.sessions().map { session ->
|
||||
// An unfolded community still gets its row (see INLINE above).
|
||||
val state = session.state.value
|
||||
val newest =
|
||||
state.channels.keys
|
||||
state
|
||||
?.channels
|
||||
?.keys
|
||||
.orEmpty()
|
||||
.mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) }
|
||||
.maxByOrNull { it.createdAt() ?: 0L }
|
||||
ConcordServerRoomNote(session.entry.id, newest)
|
||||
|
||||
+98
-27
@@ -107,13 +107,19 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Name of the default Concord community Admin role minted by "Make admin". */
|
||||
private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||
|
||||
/** How long a join waits for the new community's session before handing it the wraps it fetched. */
|
||||
private const val SESSION_WAIT_MS = 10_000L
|
||||
|
||||
/**
|
||||
* How often a joined Concord community's stored invite link is re-resolved to check whether
|
||||
* we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is
|
||||
@@ -155,9 +161,38 @@ class AccountConcordActions(
|
||||
entry: ConcordCommunityListEntry,
|
||||
inviteCreator: HexKey? = null,
|
||||
inviteLabel: String? = null,
|
||||
) {
|
||||
if (!persistConcordEntry(entry)) return
|
||||
fetchedWraps: List<Event> = emptyList(),
|
||||
): Boolean {
|
||||
// False when the List could not take the membership (not loaded, or every held fragment is
|
||||
// full while others are missing, CORD-02 §8). Callers must say so: the community would
|
||||
// otherwise look joined now and be gone after a restart.
|
||||
if (!persistConcordEntry(entry)) return false
|
||||
// The session is built asynchronously from the Community List flow. Every wrap that reaches
|
||||
// the cache before it exists is kept as an unclaimed note, and the live subscription's copy
|
||||
// of the same wrap is then deduplicated away, so the community showed "No channels yet"
|
||||
// until a restart emptied the cache. Wait for the session, then hand it what we fetched.
|
||||
awaitConcordSession(entry.id)
|
||||
fetchedWraps.forEach { account.concordSessions.ingest(it) }
|
||||
announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel)
|
||||
return true
|
||||
}
|
||||
|
||||
/** The community's current name: its folded metadata, else the name it was joined under. */
|
||||
private fun currentConcordName(
|
||||
session: ConcordCommunitySession?,
|
||||
entry: ConcordCommunityListEntry,
|
||||
): String =
|
||||
session
|
||||
?.state
|
||||
?.value
|
||||
?.metadata
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() } ?: entry.name
|
||||
|
||||
private suspend fun awaitConcordSession(communityId: HexKey) {
|
||||
withTimeoutOrNull(SESSION_WAIT_MS) {
|
||||
account.concordSessions.revision.first { account.concordSessions.sessionFor(communityId) != null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,17 +200,25 @@ class AccountConcordActions(
|
||||
* only means "no List" once the relays have been asked (CORD-02 §8 — a write built on an
|
||||
* unloaded List replaces fragments another device published).
|
||||
*/
|
||||
private suspend fun ensureConcordListLoaded() {
|
||||
if (!account.concordChannelList.relaysConfirmed) importConcordCommunities()
|
||||
suspend fun preloadConcordList() {
|
||||
if (account.concordChannelList.relaysConfirmed) return
|
||||
// Single-flight: the import is a ~30s drain of every stock/outbox relay. A join starts it
|
||||
// as soon as it begins, and the join's own List write then waits for that same fetch
|
||||
// instead of starting a second one.
|
||||
concordListImport.withLock {
|
||||
if (!account.concordChannelList.relaysConfirmed) importConcordCommunities()
|
||||
}
|
||||
}
|
||||
|
||||
private val concordListImport = Mutex()
|
||||
|
||||
/**
|
||||
* Read-modify-writes the Community List through [change] and publishes the fragments it
|
||||
* produced. Returns false — logged, never thrown into a UI coroutine — when the List can't be
|
||||
* written safely yet (fragments unreadable or not loaded) or a fragment would pass the ceiling.
|
||||
*/
|
||||
private suspend fun writeConcordList(change: suspend (ConcordChannelListState) -> List<Event>): Boolean {
|
||||
ensureConcordListLoaded()
|
||||
preloadConcordList()
|
||||
return try {
|
||||
account.sendMyPublicAndPrivateOutbox(change(account.concordChannelList))
|
||||
true
|
||||
@@ -209,7 +252,7 @@ class AccountConcordActions(
|
||||
* Create a new Concord community: mint its genesis (metadata + #general),
|
||||
* publish the owner-signed genesis wraps to [relays] (or our outbox), and add
|
||||
* the secret-bearing entry to the Community List (kind 33302). Returns the new
|
||||
* community id, or null if not writeable.
|
||||
* community id, or null if not writeable or no relay accepted the genesis.
|
||||
*/
|
||||
suspend fun createConcordCommunity(
|
||||
name: String,
|
||||
@@ -228,25 +271,38 @@ class AccountConcordActions(
|
||||
val community = ConcordActions.createCommunity(account.signer, name, TimeUtils.now(), description, relayUrls, icon)
|
||||
|
||||
val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||
community.genesisWraps.forEach { account.client.publish(it, publishTo) }
|
||||
// The genesis is the community: with no relay holding it, nobody (us included, after a
|
||||
// restart) can ever fold it. It used to be fired and forgotten, then the community saved
|
||||
// anyway, so a genesis a relay refused or an app killed mid-send left a community in the
|
||||
// List that could never load again. Confirm every genesis wrap before saving it.
|
||||
val landed =
|
||||
coroutineScope {
|
||||
community.genesisWraps.map { async { account.client.publishAndConfirm(it, publishTo) } }.awaitAll()
|
||||
}
|
||||
if (!landed.all { it }) {
|
||||
Log.w("Concord") { "createConcordCommunity: no relay in $publishTo accepted the genesis; not creating ${community.communityIdHex}" }
|
||||
return null
|
||||
}
|
||||
|
||||
joinConcordCommunity(
|
||||
ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
// The creator is the founding staff member (CORD-02 §2): it keeps the write
|
||||
// secret and publishes only the derived pubkey to everyone else.
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
relays = relayUrls,
|
||||
name = name,
|
||||
addedAt = TimeUtils.nowMillis(),
|
||||
),
|
||||
)
|
||||
return community.communityIdHex
|
||||
val saved =
|
||||
joinConcordCommunity(
|
||||
ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
// The creator is the founding staff member (CORD-02 §2): it keeps the write
|
||||
// secret and publishes only the derived pubkey to everyone else.
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
relays = relayUrls,
|
||||
name = name,
|
||||
addedAt = TimeUtils.nowMillis(),
|
||||
),
|
||||
fetchedWraps = community.genesisWraps,
|
||||
)
|
||||
return community.communityIdHex.takeIf { saved }
|
||||
}
|
||||
|
||||
// ---- CORD-05 Invite List (kind 13303) -------------------------------------
|
||||
@@ -412,6 +468,7 @@ class AccountConcordActions(
|
||||
rootEpoch = entry.rootEpoch,
|
||||
controlPk = entry.controlPk,
|
||||
relays = entry.relays,
|
||||
name = currentConcordName(account.concordSessions.sessionFor(entry.id), entry),
|
||||
)
|
||||
// Confirmed: a link counted as moved but never stored is a link its
|
||||
// holders can no longer redeem, reported as a success.
|
||||
@@ -458,7 +515,9 @@ class AccountConcordActions(
|
||||
ownerSaltHex = entry.ownerSalt,
|
||||
communityRootHex = entry.root,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
name = entry.name,
|
||||
// The folded metadata, not the List entry: the entry keeps the name it was joined
|
||||
// under, so a renamed community's invites previewed its old name.
|
||||
name = currentConcordName(session, entry),
|
||||
relays = entry.relays,
|
||||
// The joiner can never derive the Control Plane address, so the bundle carries
|
||||
// it (CORD-05 §1). Null on a legacy community, which has none to carry.
|
||||
@@ -627,6 +686,11 @@ class AccountConcordActions(
|
||||
if (!account.isWriteable()) return ConcordInviteResult.InvalidLink
|
||||
val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink
|
||||
|
||||
// Joining ends in a Community List write, which first needs the List loaded (a slow drain of
|
||||
// every stock and outbox relay). Start it now so it overlaps the bundle and plane fetches
|
||||
// below instead of running after them.
|
||||
account.scope.launch { preloadConcordList() }
|
||||
|
||||
val relays =
|
||||
(parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet()
|
||||
if (relays.isEmpty()) return ConcordInviteResult.NotReachable
|
||||
@@ -746,7 +810,7 @@ class AccountConcordActions(
|
||||
if (rejoined != null) {
|
||||
if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId)
|
||||
Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" }
|
||||
joinConcordCommunity(rejoined, creator, label)
|
||||
if (!joinConcordCommunity(rejoined, creator, label, planeWraps)) return ConcordInviteResult.NotSaved
|
||||
_strandedConcordCommunities.value -= rejoined.id
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
}
|
||||
@@ -770,7 +834,7 @@ class AccountConcordActions(
|
||||
// Anchor for stranded recovery (null for a Direct Invite, which has no link).
|
||||
inviteRef = inviteRef,
|
||||
)
|
||||
joinConcordCommunity(entry, creator, label)
|
||||
if (!joinConcordCommunity(entry, creator, label, planeWraps)) return ConcordInviteResult.NotSaved
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
}
|
||||
|
||||
@@ -1401,6 +1465,13 @@ class AccountConcordActions(
|
||||
roleId.toHexKey()
|
||||
}
|
||||
|
||||
// An Admin role published before Amethyst wrote `role_id` into Role content is invisible
|
||||
// to Armada, which then drops this Grant too. Re-issue it at the same id to heal it.
|
||||
if (existing != null && existing.value.roleId.isNullOrEmpty()) {
|
||||
val healWrap = ConcordModeration.defineRole(account.signer, cp, communityId.hexToByteArray(), roleIdHex.hexToByteArray(), existing.value, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, healWrap)
|
||||
}
|
||||
|
||||
// Admin carries every management bit, so this Grant makes its member staff: it must
|
||||
// deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds
|
||||
// authority it cannot publish under.
|
||||
|
||||
+7
@@ -69,4 +69,11 @@ sealed interface ConcordInviteResult {
|
||||
* bundle format this app can't read yet. Retrying can't help.
|
||||
*/
|
||||
data object Incompatible : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The invite was good, but the membership could not be written to the Community List: the
|
||||
* List is not loaded yet, or every fragment this device holds is full while the rest are
|
||||
* missing (CORD-02 §8). Nothing was announced. Retrying can help once the List loads.
|
||||
*/
|
||||
data object NotSaved : ConcordInviteResult
|
||||
}
|
||||
|
||||
+42
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relays.MutableTime
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -243,4 +244,45 @@ class ConcordSubscriptionPlannerTest {
|
||||
assertTrue(generalPk in otherFilter.authors.orEmpty(), "channel plane missing from the non-control filter")
|
||||
assertTrue(controlPk !in otherFilter.authors.orEmpty(), "Control Plane leaked into the Guestbook/channel filter")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJustJoinedCommunityIsFetchedWithoutTheRelaysSinceCursor() =
|
||||
runTest {
|
||||
// Regression: joining a community on a relay that already carries another one asked for its
|
||||
// Control Plane `since` that relay's EOSE cursor, so the genesis editions (older than the
|
||||
// cursor) never arrived and the community showed "No channels yet" until an app restart.
|
||||
fun entryOf(c: NewConcordCommunity) =
|
||||
com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry(
|
||||
id = c.communityIdHex,
|
||||
owner = c.ownerPubKey,
|
||||
ownerSalt = c.ownerSalt.toHexKey(),
|
||||
root = c.communityRoot.toHexKey(),
|
||||
rootEpoch = c.rootEpoch,
|
||||
controlPk = c.controlPkHex,
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "x",
|
||||
)
|
||||
val folded = ConcordCommunityFactory.create(owner, "Folded", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val joined = ConcordCommunityFactory.create(owner, "Joined", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val foldedEntry = entryOf(folded)
|
||||
val joinedEntry = entryOf(joined)
|
||||
val foldedState = ConcordActions.foldCommunity(folded.genesisWraps, folded.controlPlane, folded.communityId, folded.ownerPubKey)
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull("wss://r.example")!!
|
||||
|
||||
val filters =
|
||||
ConcordSubscriptionPlanner.controlIsolatedFilters(
|
||||
listOf(foldedEntry, joinedEntry),
|
||||
since = mutableMapOf(relay to MutableTime(1234L)),
|
||||
stateOf = { if (it.id == foldedEntry.id) foldedState else null },
|
||||
)
|
||||
|
||||
val joinedControl = filters.map { it.filter }.single { joined.controlPlane.address in it.authors.orEmpty() }
|
||||
assertNull(joinedControl.since, "an unfolded community's Control Plane must be fetched in full")
|
||||
val joinedGuestbook = ConcordActions.guestbookPlane(joined.communityRoot, joined.communityId, joined.rootEpoch).publicKeyHex
|
||||
assertNull(filters.map { it.filter }.single { joinedGuestbook in it.authors.orEmpty() }.since)
|
||||
|
||||
// The already-folded community keeps its incremental cursor.
|
||||
val foldedControl = filters.map { it.filter }.single { folded.controlPlane.address in it.authors.orEmpty() }
|
||||
assertEquals(1234L, foldedControl.since)
|
||||
}
|
||||
}
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeException
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The Community List past one fragment (CORD-02 §8), driven through the app's own
|
||||
* [ConcordChannelListState] rather than the fragment math alone: a List that outgrows one event
|
||||
* must split into several, every published event must fit a relay's event ceiling, and another
|
||||
* device holding only what was published must read back every membership with its secrets.
|
||||
*/
|
||||
class ConcordChannelListFragmentationTest {
|
||||
private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000009".hexToByteArray()))
|
||||
|
||||
private fun hex(
|
||||
seed: Int,
|
||||
salt: Int,
|
||||
): HexKey = (seed * 7919 + salt).toString(16).padStart(8, '0').repeat(8)
|
||||
|
||||
/** A realistically heavy membership: staff secrets, two held roots, three private channels. */
|
||||
private fun entry(n: Int) =
|
||||
ConcordCommunityListEntry(
|
||||
id = hex(n, 1),
|
||||
owner = hex(n, 2),
|
||||
ownerSalt = hex(n, 3),
|
||||
root = hex(n, 4),
|
||||
rootEpoch = 2,
|
||||
controlPk = hex(n, 5),
|
||||
controlRoot = hex(n, 6),
|
||||
heldRoots = listOf(HeldRoot(0, hex(n, 7)), HeldRoot(1, hex(n, 8), hex(n, 9))),
|
||||
privateChannels = (0 until 3).map { PrivateChannelKey(hex(n, 20 + it), hex(n, 30 + it), 1, "private-$it") },
|
||||
relays = listOf("wss://nos.lol/", "wss://nostr.mom/"),
|
||||
name = "Community number $n",
|
||||
addedAt = 1_000L + n,
|
||||
)
|
||||
|
||||
/** Holds the List only as published fragments, the way a relay or the offline backup does. */
|
||||
private class WireRepository(
|
||||
var fragments: List<ConcordCommunityListFragmentEvent> = emptyList(),
|
||||
) : ConcordListRepository {
|
||||
override fun concordList(): ConcordCommunityListEvent? = null
|
||||
|
||||
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) = Unit
|
||||
|
||||
override fun concordListFragments() = fragments
|
||||
|
||||
override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {
|
||||
fragments = fragments.filterNot { it.index() == fragment.index() } + fragment
|
||||
}
|
||||
}
|
||||
|
||||
private class StubCache : ICacheProvider {
|
||||
override fun getAnyChannel(note: Note): Channel? = null
|
||||
|
||||
override val relayHints = HintIndexer()
|
||||
|
||||
override fun getUserIfExists(pubkey: HexKey): User? = null
|
||||
|
||||
override fun countUsers(predicate: (String, User) -> Boolean): Int = 0
|
||||
|
||||
override fun getNoteIfExists(hexKey: HexKey): Note? = null
|
||||
|
||||
override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null
|
||||
|
||||
override fun getOrCreateAddressableNote(address: Address): AddressableNote = AddressableNote(address)
|
||||
|
||||
override fun getEventStream(): ICacheEventStream = error("not used")
|
||||
|
||||
override fun hasBeenDeleted(event: Any): Boolean = false
|
||||
|
||||
override fun getOrCreateUser(pubkey: HexKey): User? = null
|
||||
|
||||
override fun consumeEmbedded(event: Event) = Unit
|
||||
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
|
||||
}
|
||||
|
||||
private fun device(wire: List<ConcordCommunityListFragmentEvent> = emptyList()): Pair<ConcordChannelListState, WireRepository> {
|
||||
val repo = WireRepository(wire)
|
||||
val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo)
|
||||
list.markRelaysConfirmed()
|
||||
return list to repo
|
||||
}
|
||||
|
||||
private suspend fun joinAll(
|
||||
list: ConcordChannelListState,
|
||||
count: Int,
|
||||
): List<ConcordCommunityListFragmentEvent> {
|
||||
val published = ArrayList<ConcordCommunityListFragmentEvent>()
|
||||
for (n in 0 until count) published += list.follow(entry(n)).map { it as ConcordCommunityListFragmentEvent }
|
||||
return published
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLargeListSplitsIntoFragmentsThatEachFitAnEvent() =
|
||||
runTest {
|
||||
val (list, repo) = device()
|
||||
val published = joinAll(list, 250)
|
||||
|
||||
val wire = repo.fragments
|
||||
assertTrue(wire.size >= 2, "250 heavy memberships must not fit one fragment; got ${wire.size}")
|
||||
for (fragment in published) {
|
||||
val bytes = fragment.toJson().encodeToByteArray().size
|
||||
assertTrue(bytes <= ConcordListFragments.EVENT_CEILING_BYTES, "fragment ${fragment.index()} is $bytes bytes, over the ${ConcordListFragments.EVENT_CEILING_BYTES} ceiling")
|
||||
}
|
||||
assertEquals(250, list.entries().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anotherDeviceReadsEveryMembershipFromThePublishedFragments() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
|
||||
val (second, _) = device(repo.fragments)
|
||||
val read = second.entries().associateBy { it.id }
|
||||
|
||||
assertEquals(250, read.size)
|
||||
for (n in listOf(0, 1, 124, 248, 249)) {
|
||||
val want = entry(n)
|
||||
val got = read.getValue(want.id)
|
||||
assertEquals(want.root, got.root)
|
||||
assertEquals(want.controlRoot, got.controlRoot)
|
||||
assertEquals(want.heldRoots.map { it.key }, got.heldRoots.map { it.key })
|
||||
assertEquals(want.privateChannels.map { it.key }, got.privateChannels.map { it.key })
|
||||
assertEquals(want.name, got.name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editsOnAnotherDeviceSurviveTheRoundTripAcrossFragments() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
|
||||
// The second device leaves a membership that lives in a later fragment and joins a new one.
|
||||
val (second, secondRepo) = device(repo.fragments)
|
||||
second.unfollow(entry(240).id)
|
||||
second.follow(entry(900))
|
||||
|
||||
// The first device, fed what the second published, converges on the same List.
|
||||
val (third, _) = device(secondRepo.fragments)
|
||||
val ids = third.entries().map { it.id }.toSet()
|
||||
assertEquals(250, ids.size)
|
||||
assertTrue(entry(240).id !in ids, "the leave must hold across fragments")
|
||||
assertTrue(entry(900).id in ids)
|
||||
assertTrue(entry(0).id in ids && entry(249).id in ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeviceMissingFragmentsJoinsIntoAHeldFragmentWithRoom() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
val wire = repo.fragments.sortedBy { it.index() }
|
||||
assertTrue(wire.size >= 3)
|
||||
|
||||
// Holding the full fragment 0 and the partly filled last one, but not the middle ones
|
||||
// (they sit on a relay this device can't reach): the join goes where it fits.
|
||||
val (partial, partialRepo) = device(listOf(wire.first(), wire.last()))
|
||||
val written = partial.follow(entry(901)).map { it as ConcordCommunityListFragmentEvent }
|
||||
assertEquals(listOf(wire.last().index()), written.map { it.index() }, "the new membership belongs in the held fragment with room")
|
||||
|
||||
// Nothing it never held was rewritten: once the middle fragments arrive, nothing is lost.
|
||||
val merged = (partialRepo.fragments + wire).groupBy { it.index() }.map { (_, copies) -> copies.maxBy { it.createdAt } }
|
||||
val (reader, _) = device(merged)
|
||||
val ids = reader.entries().map { it.id }.toSet()
|
||||
assertEquals(251, ids.size, "every membership from every fragment plus the new join")
|
||||
assertTrue(entry(901).id in ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeviceHoldingOnlyFullFragmentsRefusesTheJoinRatherThanOverflowing() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
val fragmentZero = repo.fragments.first { it.index() == 0 }
|
||||
|
||||
// Opening a new fragment is a repack, which needs the complete List (CORD-02 §8); an
|
||||
// oversized event would be refused by relays. Refusing here is the only safe answer,
|
||||
// and the caller must surface it.
|
||||
val (partial, partialRepo) = device(listOf(fragmentZero))
|
||||
assertFailsWith<ConcordListTooLargeException> { partial.follow(entry(902)) }
|
||||
assertEquals(listOf(fragmentZero), partialRepo.fragments, "a refused write publishes nothing")
|
||||
}
|
||||
}
|
||||
@@ -3639,6 +3639,7 @@
|
||||
<string name="concord_channel_rename_save">Rename</string>
|
||||
<string name="concord_channels_empty">No channels yet.</string>
|
||||
<string name="concord_create_action">Create</string>
|
||||
<string name="concord_create_failed">The community could not be created: no relay accepted it, or your community list could not be updated. Check the relays and try again.</string>
|
||||
<string name="concord_create_relays">Relays</string>
|
||||
<string name="concord_create_relays_desc">Relays that store this community's encrypted messages. Leave empty to use your own.</string>
|
||||
<string name="concord_create_title">New Concord Channel</string>
|
||||
@@ -3668,6 +3669,7 @@
|
||||
<string name="concord_invite_failed_expired">This invite link has expired and can no longer be used. Ask for a fresh link.</string>
|
||||
<string name="concord_invite_failed_incompatible">This invite link can't be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link.</string>
|
||||
<string name="concord_invite_failed_invalid">This invite link is invalid or can't be opened with this account.</string>
|
||||
<string name="concord_invite_failed_not_saved">The invite works, but your community list could not be updated, so you were not joined. Try again in a moment.</string>
|
||||
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
|
||||
<string name="concord_invite_links_action">Invite links…</string>
|
||||
<string name="concord_invite_links_title">Invite links</string>
|
||||
@@ -3684,6 +3686,8 @@
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<string name="concord_members_ban">Ban</string>
|
||||
<string name="concord_members_ban_title">Ban member?</string>
|
||||
<string name="concord_members_ban_message">They can no longer post, and their messages are hidden from everyone. Other apps may remove the community from their list. They can still read new messages until you also remove them from the community. You can unban them later.</string>
|
||||
<string name="concord_members_make_admin">Make admin</string>
|
||||
<string name="concord_members_remove_admin">Remove admin</string>
|
||||
<string name="concord_members_roles_failed">Could not update this member's roles.</string>
|
||||
|
||||
+2
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_home_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_not_saved
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ConcordInvitePreviewRow
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
@@ -252,6 +253,7 @@ private fun ConcordDirectInviteCard(
|
||||
is ConcordInviteResult.Expired -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_expired)
|
||||
is ConcordInviteResult.Banned -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_banned)
|
||||
is ConcordInviteResult.InvalidLink -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_invalid)
|
||||
is ConcordInviteResult.NotSaved -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_not_saved)
|
||||
else -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_direct_invite_accept_failed)
|
||||
}
|
||||
} finally {
|
||||
|
||||
+3
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_incompatible
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_not_saved
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_revoked
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_preview_explainer
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_invite_preview_relays
|
||||
@@ -144,6 +145,8 @@ fun ConcordInviteScreen(
|
||||
RedeemState.Failed(Res.string.concord_invite_failed_expired, canRetry = false)
|
||||
is ConcordInviteResult.NotReachable ->
|
||||
RedeemState.Failed(Res.string.concord_invite_failed, canRetry = true)
|
||||
is ConcordInviteResult.NotSaved ->
|
||||
RedeemState.Failed(Res.string.concord_invite_failed_not_saved, canRetry = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-6
@@ -63,6 +63,8 @@ import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.back
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_ban
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_empty
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_make_admin
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_remove
|
||||
@@ -92,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||
|
||||
/**
|
||||
@@ -289,9 +292,28 @@ private fun ConcordMemberRow(
|
||||
)
|
||||
}
|
||||
|
||||
// A ban is reversible here, but not for the banned member: clients such as Armada drop the
|
||||
// community from a banned member's list on sight, so a mis-tap still costs them the community.
|
||||
var confirmBan by remember { mutableStateOf(false) }
|
||||
if (confirmBan) {
|
||||
ConcordConfirmMemberActionDialog(
|
||||
title = Res.string.concord_members_ban_title,
|
||||
message = Res.string.concord_members_ban_message,
|
||||
confirm = Res.string.concord_members_ban,
|
||||
onConfirm = {
|
||||
accountViewModel.setConcordBan(communityId, entry.pubkey, ban = true)
|
||||
confirmBan = false
|
||||
},
|
||||
onDismiss = { confirmBan = false },
|
||||
)
|
||||
}
|
||||
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
if (confirmRemove) {
|
||||
ConcordRemoveMemberDialog(
|
||||
ConcordConfirmMemberActionDialog(
|
||||
title = Res.string.concord_members_remove_title,
|
||||
message = Res.string.concord_members_remove_message,
|
||||
confirm = Res.string.concord_members_remove_confirm,
|
||||
onConfirm = {
|
||||
accountViewModel.removeConcordMember(communityId, entry.pubkey)
|
||||
confirmRemove = false
|
||||
@@ -357,7 +379,8 @@ private fun ConcordMemberRow(
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (isBanned) Res.string.concord_members_unban else Res.string.concord_members_ban)) },
|
||||
onClick = {
|
||||
accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned)
|
||||
// Unbanning restores access, so only a ban asks first.
|
||||
if (isBanned) accountViewModel.setConcordBan(communityId, entry.pubkey, ban = false) else confirmBan = true
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
@@ -471,17 +494,20 @@ private fun ConcordRolesDialog(
|
||||
|
||||
/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */
|
||||
@Composable
|
||||
private fun ConcordRemoveMemberDialog(
|
||||
private fun ConcordConfirmMemberActionDialog(
|
||||
title: StringResource,
|
||||
message: StringResource,
|
||||
confirm: StringResource,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(Res.string.concord_members_remove_title)) },
|
||||
text = { Text(stringRes(Res.string.concord_members_remove_message)) },
|
||||
title = { Text(stringRes(title)) },
|
||||
text = { Text(stringRes(message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringRes(Res.string.concord_members_remove_confirm), color = MaterialTheme.colorScheme.error)
|
||||
Text(stringRes(confirm), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
|
||||
+4
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.concord.cord02Community
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -309,6 +310,8 @@ object ConcordCommunityList {
|
||||
@SerialName(EXTRAS) val extras: JsonObject = NoExtras,
|
||||
)
|
||||
|
||||
// `name` is always written: Armada refuses to serialize a list entry whose name is not a string.
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
private class JoinMaterialWire(
|
||||
@SerialName("community_id") val communityId: String,
|
||||
@@ -323,7 +326,7 @@ object ConcordCommunityList {
|
||||
WireChannel,
|
||||
> = emptyList(),
|
||||
val relays: List<String> = emptyList(),
|
||||
val name: String = "",
|
||||
@EncodeDefault val name: String = "",
|
||||
@SerialName("held_roots") val heldRoots: List<
|
||||
@Serializable(WireHeldRootSerializer::class)
|
||||
WireHeldRoot,
|
||||
|
||||
+16
-1
@@ -142,7 +142,22 @@ class ConcordListFragmentSet private constructor(
|
||||
val targets = HashMap<Int, MutableSet<String>>()
|
||||
for (id in changed) {
|
||||
val holders = inRange.filter { id in ConcordListFragments.idsIn(held.getValue(it).fragment.doc) }
|
||||
for (i in holders.ifEmpty { listOf(inRange.first()) }) targets.getOrPut(i) { HashSet() }.add(id)
|
||||
if (holders.isNotEmpty()) {
|
||||
for (i in holders) targets.getOrPut(i) { HashSet() }.add(id)
|
||||
continue
|
||||
}
|
||||
// A new membership may go into any held fragment (CORD-02 §8 scopes the write to the
|
||||
// fragment it touches). Take the lowest one it still fits in: always taking the lowest
|
||||
// refused every join once fragment 0 filled, even with room in another held fragment.
|
||||
// When none fits, the lowest is kept and the size guard below refuses the write, since
|
||||
// opening a new fragment is a repack and needs the complete List.
|
||||
val target =
|
||||
inRange.firstOrNull { i ->
|
||||
val ids = targets[i].orEmpty() + id
|
||||
val plaintext = ConcordListFragments.rewriteFragment(held.getValue(i).fragment.doc, newDoc, ids, declared)
|
||||
ConcordListFragments.projectedEventBytes(plaintext.encodeToByteArray().size) <= ConcordListFragments.EVENT_CEILING_BYTES
|
||||
} ?: inRange.first()
|
||||
targets.getOrPut(target) { HashSet() }.add(id)
|
||||
}
|
||||
for ((i, ids) in targets.entries.sortedBy { it.key }) {
|
||||
val plaintext = ConcordListFragments.rewriteFragment(held.getValue(i).fragment.doc, newDoc, ids, declared)
|
||||
|
||||
+13
-4
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.concord.cord05Invites
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.LenientImagePointerSerializer
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -50,14 +52,21 @@ class InviteChannel(
|
||||
* Field names are pinned to the Concord v2 reference client (snake_case on the
|
||||
* wire) so bundles interoperate. This object is JSON-serialized and encrypted —
|
||||
* into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites).
|
||||
*
|
||||
* [rootEpoch], [channels], [relays] and [name] are always written, even at their defaults:
|
||||
* Armada rejects join material whose `root_epoch` is not a number or whose `name` is not a
|
||||
* string, and Accordion rejects a bundle missing `root_epoch`, `channels` or `relays`. A
|
||||
* fresh public community (epoch 0, no private channel grants) was unjoinable from both while
|
||||
* the encoder dropped them.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class CommunityInvite(
|
||||
@SerialName("community_id") val communityId: String,
|
||||
val owner: String,
|
||||
@SerialName("owner_salt") val ownerSalt: String,
|
||||
@SerialName("community_root") val communityRoot: String,
|
||||
@SerialName("root_epoch") val rootEpoch: Long = 0,
|
||||
@EncodeDefault @SerialName("root_epoch") val rootEpoch: Long = 0,
|
||||
/**
|
||||
* The Control Plane's signer pubkey at [rootEpoch] (CORD-02 §5): subscribe,
|
||||
* verify, read — never write. Absent = a legacy, pre-split Community; the
|
||||
@@ -71,9 +80,9 @@ data class CommunityInvite(
|
||||
* Roster, and a later base rotation re-delivers the true key.
|
||||
*/
|
||||
@SerialName("control_pk") val controlPk: String? = null,
|
||||
val channels: List<InviteChannel> = emptyList(),
|
||||
val relays: List<String> = emptyList(),
|
||||
val name: String = "",
|
||||
@EncodeDefault val channels: List<InviteChannel> = emptyList(),
|
||||
@EncodeDefault val relays: List<String> = emptyList(),
|
||||
@EncodeDefault val name: String = "",
|
||||
@Serializable(with = LenientImagePointerSerializer::class) val icon: ImagePointer? = null,
|
||||
@SerialName("expires_at") val expiresAt: Long? = null,
|
||||
@SerialName("creator_npub") val creatorNpub: String? = null,
|
||||
|
||||
+6
-2
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
@@ -78,11 +79,14 @@ object ConcordStreamEnvelope {
|
||||
authorSigner: NostrSigner,
|
||||
encrypted: Boolean,
|
||||
): Event {
|
||||
// A rumor is unsigned (NIP-59): serialize it without `sig`. applesauce clients read
|
||||
// `"sig": ""` as a signed event with a bad signature and drop it.
|
||||
val rumorJson = Rumor.toJson(Rumor.create(rumor))
|
||||
val content =
|
||||
if (encrypted) {
|
||||
encryptChecked(rumor.toJson(), stream.conversationKey)
|
||||
encryptChecked(rumorJson, stream.conversationKey)
|
||||
} else {
|
||||
rumor.toJson()
|
||||
rumorJson
|
||||
}
|
||||
val kind = if (encrypted) KIND_SEAL_ENCRYPTED else KIND_SEAL_PLAINTEXT
|
||||
return authorSigner.sign(rumor.createdAt, kind, EMPTY_TAGS, content)
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.quartz.concord.cord05Invites
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class CommunityInviteWireTest {
|
||||
private val hex = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun freshCommunityInviteStillCarriesEveryRequiredField() {
|
||||
// A genesis public community sits at epoch 0 with no private channel grants: these are
|
||||
// all Kotlin defaults, and Armada/Accordion refuse the join unless they are on the wire.
|
||||
val invite = CommunityInvite(communityId = hex, owner = hex, ownerSalt = hex, communityRoot = hex)
|
||||
val json = ConcordJson.instance.encodeToJsonElement(CommunityInvite.serializer(), invite).jsonObject
|
||||
|
||||
assertEquals(0L, json["root_epoch"]?.jsonPrimitive?.long)
|
||||
assertEquals("", json["name"]?.jsonPrimitive?.content)
|
||||
assertEquals(0, json["channels"]?.jsonArray?.size)
|
||||
assertEquals(0, json["relays"]?.jsonArray?.size)
|
||||
}
|
||||
}
|
||||
+19
@@ -28,8 +28,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -69,6 +73,21 @@ class ConcordStreamEnvelopeTest {
|
||||
assertEquals(9, opened.rumor.kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* A rumor is unsigned (NIP-59): its JSON carries no `sig`. applesauce-based clients
|
||||
* (Accordion) treat an object with `"sig": ""` as a signed event whose signature fails,
|
||||
* and silently dropped every Concord message and Guestbook Join we sent.
|
||||
*/
|
||||
@Test
|
||||
fun sealedRumorJsonCarriesNoSig() =
|
||||
runTest {
|
||||
val seal = ConcordStreamEnvelope.seal(chatRumor("no sig"), stream, authorSigner, encrypted = false)
|
||||
val rumorJson = Json.parseToJsonElement(seal.content).jsonObject
|
||||
|
||||
assertFalse("sig" in rumorJson, "rumor JSON must not carry a sig: ${seal.content}")
|
||||
assertEquals(chatRumor("no sig").id, rumorJson["id"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptedSealRoundTrips() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user