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 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-29 15:18:36 -04:00
co-authored by Claude Opus 5.5
parent cd5c92bd1b
commit 524d0f7031
4 changed files with 39 additions and 23 deletions
@@ -146,8 +146,11 @@ class AccountConcordActions(
inviteCreator: HexKey? = null,
inviteLabel: String? = null,
fetchedWraps: List<Event> = 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)
}
@@ -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
}
@@ -3614,7 +3614,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">No relay accepted the new community, so it was not created. Check the relays and try again.</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>
@@ -3627,6 +3627,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>
@@ -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)
}
}
}