From 524d0f7031849bd7c14081b2757313c22f868329 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 15:18:36 -0400 Subject: [PATCH] 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) } } }