From a71bb56b6cf9085b9b80969916158a3778bb821f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 12:31:04 -0400 Subject: [PATCH 01/12] fix(concord): always write required invite and join-material fields ConcordJson encodes without defaults, so a fresh public community's invite lost root_epoch (0) and its empty channels list. Armada refused the join as malformed join material and Accordion as failed verification. Force root_epoch, channels, relays and name onto the wire. Co-Authored-By: Claude Opus 5.5 --- .../cord02Community/ConcordCommunityList.kt | 5 +- .../concord/cord05Invites/CommunityInvite.kt | 17 +++++-- .../cord05Invites/CommunityInviteWireTest.kt | 46 +++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInviteWireTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index ceda69c3cd..68d9275e3d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -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 = emptyList(), - val name: String = "", + @EncodeDefault val name: String = "", @SerialName("held_roots") val heldRoots: List< @Serializable(WireHeldRootSerializer::class) WireHeldRoot, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index 632a7f9e03..14bb259a05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -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 = emptyList(), - val relays: List = emptyList(), - val name: String = "", + @EncodeDefault val channels: List = emptyList(), + @EncodeDefault val relays: List = 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, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInviteWireTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInviteWireTest.kt new file mode 100644 index 0000000000..b061103c89 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInviteWireTest.kt @@ -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) + } +} From 9c2cccd68d46f5968594063f5d8b5144d7309d3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 12:31:06 -0400 Subject: [PATCH 02/12] fix(concord): re-issue a legacy Admin role without role_id on Make admin An Admin role published before Role content carried role_id is dropped by Armada, along with every Grant naming it, so promoting a member silently did nothing there. Re-define it at the same id before granting. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/commons/model/AccountConcordActions.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 0ebe09d709..71d107fb9f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -1126,6 +1126,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. From 99513a92b670ea6011b0a8a7cea1d4cd7ccd4c95 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 12:47:53 -0400 Subject: [PATCH 03/12] fix(concord): seal rumors without a sig field The envelope serialized the rumor with Event.toJson, which writes "sig": "". A rumor is unsigned (NIP-59), and applesauce-based clients such as Accordion read an object with a string sig as a signed event, fail its signature and drop it, so Accordion never showed a single Concord message, Join or edition we sent. Serialize through Rumor, as NIP-59 seals already do. Co-Authored-By: Claude Opus 5.5 --- .../concord/envelope/ConcordStreamEnvelope.kt | 8 ++++++-- .../envelope/ConcordStreamEnvelopeTest.kt | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index a2bda26d86..a3e9ee9172 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt index e9fc72f3fb..50e0f9e1fa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt @@ -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 { From 89a8e658dc1c8894084ab82ea2cf295ff3d54360 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 13:35:19 -0400 Subject: [PATCH 04/12] fix(concord): show a joined community's channels without a restart Joining fetched the Control Plane before the community's session existed. Those wraps reached LocalCache as unclaimed notes, the live subscription's copies of the same wraps were deduplicated away, and the community sat on "No channels yet" until a restart emptied the cache. The join now waits for the session and hands it the wraps it fetched (creation hands over its genesis the same way). The live plane subscription also applied each relay's since cursor, set by the other communities on it, to a community whose Control Plane had not folded yet, skipping its older editions. Unfolded communities are now requested in full. The Community List load a join must finish before writing (a ~30s drain of every stock and outbox relay) now starts when the join starts and is single flight, so it overlaps the bundle and plane fetches. Co-Authored-By: Claude Opus 5.5 --- .../actions/ConcordSubscriptionPlanner.kt | 18 +++++--- .../commons/model/AccountConcordActions.kt | 45 ++++++++++++++++--- .../actions/ConcordSubscriptionPlannerTest.kt | 42 +++++++++++++++++ 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 18f47edd00..fb32df16ba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -258,16 +258,22 @@ object ConcordSubscriptionPlanner { accountPubKey: HexKey? = null, stateOf: (ConcordCommunityListEntry) -> ConcordCommunityState?, ): List { - 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() - 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() } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 71d107fb9f..83d8cc5d4a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -92,10 +92,18 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +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 @@ -137,27 +145,48 @@ class AccountConcordActions( entry: ConcordCommunityListEntry, inviteCreator: HexKey? = null, inviteLabel: String? = null, + fetchedWraps: List = emptyList(), ) { if (!persistConcordEntry(entry)) return + // 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) } + private suspend fun awaitConcordSession(communityId: HexKey) { + withTimeoutOrNull(SESSION_WAIT_MS) { + account.concordSessions.revision.first { account.concordSessions.sessionFor(communityId) != null } + } + } + /** * Makes the Community List fetched before any write can depend on it: an empty fragment set * 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): Boolean { - ensureConcordListLoaded() + preloadConcordList() return try { account.sendMyPublicAndPrivateOutbox(change(account.concordChannelList)) true @@ -227,6 +256,7 @@ class AccountConcordActions( name = name, addedAt = TimeUtils.nowMillis(), ), + fetchedWraps = community.genesisWraps, ) return community.communityIdHex } @@ -540,6 +570,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 @@ -628,7 +663,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, inviteCreator, inviteLabel) + joinConcordCommunity(rejoined, inviteCreator, inviteLabel, planeWraps) _strandedConcordCommunities.value -= rejoined.id return ConcordInviteResult.Joined(bundle.communityId) } @@ -654,7 +689,7 @@ class AccountConcordActions( // recoverStrandedConcordCommunities(). inviteRef = ConcordActions.bareInviteRef(url), ) - joinConcordCommunity(entry, inviteCreator, inviteLabel) + joinConcordCommunity(entry, inviteCreator, inviteLabel, planeWraps) return ConcordInviteResult.Joined(bundle.communityId) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index a96427d84c..d40647097a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -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) + } } From 9d29304b8186495b5d7bddffca42e1bcf1d7c0ce Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 13:44:29 -0400 Subject: [PATCH 05/12] fix(concord): put the community's current name in the invites we mint Invites carried the Community List entry's name, which is the name the community had when we joined, so a renamed community's links previewed its old name in every client. Use the folded metadata name, falling back to the entry's, both when minting and when a Refounding re-mints existing links. Co-Authored-By: Claude Opus 5.5 --- .../commons/model/AccountConcordActions.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 83d8cc5d4a..d9229873d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -157,6 +157,18 @@ class AccountConcordActions( announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) } + /** 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 } @@ -381,6 +393,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. @@ -427,7 +440,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. From 9414da7d126f6491bd87c7ac1c6677cc77e59f40 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 13:47:37 -0400 Subject: [PATCH 06/12] fix(concord): drop the create form from the back stack after creating newStack only popped up to the new community's route, which was never on the stack, so Back from a just-created community reopened the filled-in form. Pop the form like the Marmot and relay-group creators do. Co-Authored-By: Claude Opus 5.5 --- .../chats/publicChannels/concord/ConcordCreateScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 5f7549b8fe..5429ace9f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -136,7 +136,10 @@ 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) } }, enabled = name.value.isNotBlank() && !working, From 86a5da175a48bc8cb1a7e2461e46f950a25f4272 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 13:54:48 -0400 Subject: [PATCH 07/12] fix(concord): confirm before banning a member Ban fired straight from the overflow menu while Remove asked first. A ban can be lifted here, but other clients (Armada) drop the community from a banned member's list on sight, so a mis-tap still costs them the community. Ask first, reusing the Remove dialog; unban stays one tap. Co-Authored-By: Claude Opus 5.5 --- .../composeResources/values/strings.xml | 2 + .../concord/ConcordMembersScreen.kt | 38 ++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1003a3ee26..d83cb04e80 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3642,6 +3642,8 @@ %1$d members Ban + Ban member? + 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. Make admin Remove admin Could not update this member's roles. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 8df45dbba3..0ee288bd3b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -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 = { From 184758015b1f1227e23be22fe506abf0670be99e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 13:56:15 -0400 Subject: [PATCH 08/12] fix(amy): hide banned members' messages in concord read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read folded the community state but never applied its Banlist, so amy showed messages every other client drops (CORD-04 §4). Leave them out and report how many in hidden_banned, so interop checks can still see they arrived. Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 2 +- .../amethyst/cli/commands/ConcordChannelCommands.kt | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cli/README.md b/cli/README.md index 519e277554..fb4c9af8d5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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). | | `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | | `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 312c3b88d3..073a326bb4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -157,13 +157,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) }, ), ) From 07c1a7489e7a9f44e77d36bf27b08705e47a2c6e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 14:59:05 -0400 Subject: [PATCH 09/12] fix(concord): only create a community once a relay holds its genesis The genesis (metadata + #general) was published fire-and-forget and the community saved to the List regardless. If no relay stored it, because the relay refused it or the app was killed mid-send (routine on low-memory tablets), the community sat in the List forever with nothing anyone could fold, including us after a restart. Reproduced on the SM-T220: the genesis of a community force-stopped right after creation never reached relay.us.whitenoise.chat. Creation now confirms every genesis wrap with at least one relay before saving, and the create screen says so when none accepts it. Co-Authored-By: Claude Opus 5.5 --- .../publicChannels/concord/ConcordCreateScreen.kt | 7 ++++++- .../commons/model/AccountConcordActions.kt | 15 +++++++++++++-- .../composeResources/values/strings.xml | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 5429ace9f9..6efc450133 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -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 @@ -139,7 +140,11 @@ fun ConcordCreateScreen( // 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) + 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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index d9229873d9..e75c1f3fb8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -232,7 +232,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, @@ -251,7 +251,18 @@ 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( diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index d83cb04e80..f1900d96b2 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3614,6 +3614,7 @@ Rename No channels yet. Create + No relay accepted the new community, so it was not created. Check the relays and try again. Relays Relays that store this community's encrypted messages. Leave empty to use your own. New Concord Channel From f4688b57537aed8ff81ea485ca4b0f65a2ed0f38 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 14:59:06 -0400 Subject: [PATCH 10/12] fix(concord): list joined communities in Messages before they fold Messages built Concord rows only from a folded Control Plane, so a community whose planes had not loaded (just joined, a genesis that never landed, or a relay that dropped it) had no row at all: no way to open it and no way to leave it, which read as the community being lost. Such a community now gets its community row in both view modes, titled with the name it was joined under until its metadata folds. Co-Authored-By: Claude Opus 5.5 --- .../chats/rooms/ChatroomHeaderCompose.kt | 16 +++++++++------- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index bf4d44624d..0ce5c3e505 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index b0ddbebba3..0493032473 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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() + // 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(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) From cd5c92bd1bc79b794d47a615923be12f0f9c80ed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 15:18:34 -0400 Subject: [PATCH 11/12] fix(concord): put a new membership in any held List fragment it fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With part of a fragmented Community List missing, a new membership always went to the lowest held fragment, so once fragment 0 filled every join was refused even with room in another fragment the device held. Take the lowest held fragment it still fits in; refuse only when none does, since opening a new fragment is a repack that needs the complete List (CORD-02 §8). Adds an end-to-end test of the List past one fragment through ConcordChannelListState: 250 heavy memberships split into fragments that each fit the 64 KiB event ceiling (measured on the signed JSON), another device reads every membership and secret back from the published fragments, cross-device leave and join converge, and a partial device neither overflows nor erases the fragments it never held. Co-Authored-By: Claude Opus 5.5 --- .../ConcordChannelListFragmentationTest.kt | 231 ++++++++++++++++++ .../cord02Community/ConcordListFragmentSet.kt | 17 +- 2 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt new file mode 100644 index 0000000000..8d401335be --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt @@ -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 = 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 = emptyList()): Pair { + 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 { + val published = ArrayList() + 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 { partial.follow(entry(902)) } + assertEquals(listOf(fragmentZero), partialRepo.fragments, "a refused write publishes nothing") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt index 21afc71596..231f722fce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt @@ -142,7 +142,22 @@ class ConcordListFragmentSet private constructor( val targets = HashMap>() 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) From 524d0f7031849bd7c14081b2757313c22f868329 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 15:18:36 -0400 Subject: [PATCH 12/12] fix(concord): tell the user when a join or creation could not be saved When the Community List refused a membership (not loaded yet, or every held fragment full with others missing), the invite flow still reported Joined and creation still returned the new community. It looked joined until the next restart, then was gone. The join now fails with a retryable NotSaved and announces nothing, and creation returns null so the create screen shows its failure message. Co-Authored-By: Claude Opus 5.5 --- .../commons/model/AccountConcordActions.kt | 49 ++++++++++--------- .../commons/model/ConcordInviteResult.kt | 7 +++ .../composeResources/values/strings.xml | 3 +- .../concord/ConcordInviteScreen.kt | 3 ++ 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index e75c1f3fb8..e1b0b1b309 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -146,8 +146,11 @@ class AccountConcordActions( inviteCreator: HexKey? = null, inviteLabel: String? = null, fetchedWraps: List = emptyList(), - ) { - if (!persistConcordEntry(entry)) return + ): 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" @@ -155,6 +158,7 @@ class AccountConcordActions( 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. */ @@ -264,24 +268,25 @@ class AccountConcordActions( 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(), - ), - fetchedWraps = community.genesisWraps, - ) - 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) ------------------------------------- @@ -689,7 +694,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, inviteCreator, inviteLabel, planeWraps) + if (!joinConcordCommunity(rejoined, inviteCreator, inviteLabel, planeWraps)) return ConcordInviteResult.NotSaved _strandedConcordCommunities.value -= rejoined.id return ConcordInviteResult.Joined(bundle.communityId) } @@ -715,7 +720,7 @@ class AccountConcordActions( // recoverStrandedConcordCommunities(). inviteRef = ConcordActions.bareInviteRef(url), ) - joinConcordCommunity(entry, inviteCreator, inviteLabel, planeWraps) + if (!joinConcordCommunity(entry, inviteCreator, inviteLabel, planeWraps)) return ConcordInviteResult.NotSaved return ConcordInviteResult.Joined(bundle.communityId) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt index bd189e9eec..bb33934c49 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt @@ -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 } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index f1900d96b2..4103af3df7 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3614,7 +3614,7 @@ Rename No channels yet. Create - No relay accepted the new community, so it was not created. Check the relays and try again. + The community could not be created: no relay accepted it, or your community list could not be updated. Check the relays and try again. Relays Relays that store this community's encrypted messages. Leave empty to use your own. New Concord Channel @@ -3627,6 +3627,7 @@ This invite link has expired and can no longer be used. Ask for a fresh link. 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. This invite link is invalid or can't be opened with this account. + The invite works, but your community list could not be updated, so you were not joined. Try again in a moment. This invite link has been revoked and can no longer be used. Ask for a new one. Invite links… Invite links diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index a72355ef84..7086c9ef1d 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -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) } } }