fix(concord): accept a Direct Invite only once

Hiding the card while an accept runs isn't a lock: two taps (or the hub
and Notifications at once) ran two joins and announced two Guestbook
JOINs. The accepting set is now claimed with an atomic check-and-set.
A second accept returns the new InProgress result, which the card
ignores because the accept already running reports the outcome.
Verified: a double tap on the emulator left exactly one JOIN on the
guestbook plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-30 10:51:34 -04:00
co-authored by Claude Opus 5.5
parent a3ac36dab8
commit 10408bbd4d
4 changed files with 19 additions and 1 deletions
@@ -1169,7 +1169,14 @@ class AccountConcordActions(
*/
suspend fun acceptConcordDirectInvite(wrapId: HexKey): ConcordInviteResult {
if (!account.isWriteable()) return ConcordInviteResult.InvalidLink
acceptingConcordDirectInvites.update { it + wrapId }
// Check-and-set in one step: two accepts of one invite would run two joins and announce two
// Guestbook JOINs.
var claimed = false
acceptingConcordDirectInvites.update { current ->
claimed = wrapId !in current
if (claimed) current + wrapId else current
}
if (!claimed) return ConcordInviteResult.InProgress
try {
return acceptConcordDirectInviteNow(wrapId)
} finally {
@@ -76,4 +76,10 @@ sealed interface ConcordInviteResult {
* missing (CORD-02 §8). Nothing was announced. Retrying can help once the List loads.
*/
data object NotSaved : ConcordInviteResult
/**
* This invite is already being accepted (a second tap, or the hub and Notifications at once).
* Nothing was done; the accept already running reports the outcome.
*/
data object InProgress : ConcordInviteResult
}
@@ -334,6 +334,8 @@ fun ConcordDirectInviteCard(
try {
when (val result = accountViewModel.account.concord.acceptConcordDirectInvite(invite.wrapId)) {
is ConcordInviteResult.Joined -> nav.nav(Route.ConcordServer(result.communityId))
// The accept already running reports; this tap did nothing.
is ConcordInviteResult.InProgress -> Unit
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)
@@ -147,6 +147,9 @@ fun ConcordInviteScreen(
RedeemState.Failed(Res.string.concord_invite_failed, canRetry = true)
is ConcordInviteResult.NotSaved ->
RedeemState.Failed(Res.string.concord_invite_failed_not_saved, canRetry = true)
// Only a Direct Invite accept reports this; a link redeem never does.
is ConcordInviteResult.InProgress ->
RedeemState.Failed(Res.string.concord_invite_failed, canRetry = true)
}
}
}