Skip to content

Commit d422b19

Browse files
committed
Fix pending invite stuck after navigation (#514)
## Problem When a joiner scans an invite and navigates away before the inviter approves, the conversation permanently stays as a "pending invite" and never transitions to an active conversation. This was reported by Andy Yang — stuck for 45+ minutes on "Balloon Animals". ## Root Cause Three gaps in the sync/stream lifecycle: 1. **No post-sync group discovery** — after syncing, the app never checked for XMTP groups that were added while streams were stopped 2. **Resume didn't re-sync** — `handleResume()` only restarted streams without re-syncing, missing groups added during pause 3. **ValueObservation cancelled on navigation** — the DB observation watching for the draft→active transition was tied to the conversation view's lifecycle ## Fix ### 1. Post-sync group discovery (`SyncingManager`) New `discoverNewConversations()` method lists all XMTP groups after every sync and processes any missing from the local DB. Acts as a safety net for missed stream events. ### 2. Re-sync on resume (`SyncingManager`) `handleResume()` now calls `syncAllConversations` + `discoverNewConversations` + `processJoinRequestsAfterSync` instead of only restarting streams. ### 3. Restart observation on navigation (`ConversationStateMachine`) `handleUseExisting()` checks if the conversation is still a draft with an inviteTag and restarts `waitForJoinedConversation` observation in the background. ### 4. Accessibility fix Added `accessibilityIdentifier("close-new-conversation")` to the close button on `NewConversationView`. ## Testing - 59 unit tests pass - QA test 24 (pending invite recovery) passed end-to-end: - Joiner scans invite → navigates away → inviter approves via CLI → conversation automatically transitions to active → messaging works ## Files Changed - `ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift` — post-sync discovery + resume re-sync - `ConvosCore/Sources/ConvosCore/Inboxes/ConversationStateMachine.swift` — restart pending invite observation - `Convos/Conversation Creation/NewConversationView.swift` — close button accessibility ID - `docs/plans/fix-pending-invite-stuck.md` — plan doc - `qa/tests/24-pending-invite-recovery.md` — QA test <!-- Macroscope's pull request summary starts here --> <!-- Macroscope will only edit the content between these invisible markers, and the markers themselves will not be visible in the GitHub rendered markdown. --> <!-- If you delete either of the start / end markers from your PR's description, Macroscope will append its summary at the bottom of the description. --> > [!NOTE] > ### Recover pending invites after navigation by observing draft `inviteTag` in `ConversationStateMachine.handleUseExisting` and backfilling missed conversations in `SyncingManager` > Add background observation for draft invites in `ConversationStateMachine` to transition to the joined conversation, and backfill missed conversations on sync and resume in `SyncingManager`; also add a close button accessibility identifier in the new conversation view. Key entry points: [`ConvosCore/Sources/ConvosCore/Inboxes/ConversationStateMachine.swift`](https://github.com/xmtplabs/convos-ios/pull/514/files#diff-a1762ce564035d6cdd7e3b2d3200cfe0db6c3ec5fac03636392780086ba58344), [`ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift`](https://github.com/xmtplabs/convos-ios/pull/514/files#diff-ba2362cf646b78283a1af17654ea8d2c18f6b1cb686a78e7761e10f1b0328101), [`Convos/Conversation Creation/NewConversationView.swift`](https://github.com/xmtplabs/convos-ios/pull/514/files#diff-be8eeec29bfb3f245f16cd6258d1ef28ced1aa75bad52187e34243f915b084f6). > > #### 📍Where to Start > Start with `ConversationStateMachine.handleUseExisting` and the new helpers in [ConversationStateMachine.swift](https://github.com/xmtplabs/convos-ios/pull/514/files#diff-a1762ce564035d6cdd7e3b2d3200cfe0db6c3ec5fac03636392780086ba58344), then review `SyncingManager.handleSyncComplete` and `handleResume` plus `discoverNewConversations` in [SyncingManager.swift](https://github.com/xmtplabs/convos-ios/pull/514/files#diff-ba2362cf646b78283a1af17654ea8d2c18f6b1cb686a78e7761e10f1b0328101). > > <!-- Macroscope's review summary starts here --> > > <sup><a href="https://app.macroscope.com">Macroscope</a> summarized e6abb51.</sup> > <!-- Macroscope's review summary ends here --> > <!-- macroscope-ui-refresh --> <!-- Macroscope's pull request summary ends here -->
1 parent ad10e79 commit d422b19

5 files changed

Lines changed: 276 additions & 9 deletions

File tree

Convos/Conversation Creation/NewConversationView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ struct NewConversationView: View {
5353
Button(role: .close) {
5454
dismiss()
5555
}
56+
.accessibilityIdentifier("close-new-conversation")
5657
}
5758
}
5859
}

ConvosCore/Sources/ConvosCore/Inboxes/ConversationStateMachine.swift

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,62 @@ public actor ConversationStateMachine {
447447
conversationId: conversationId,
448448
origin: .existing
449449
)))
450+
451+
if DBConversation.isDraft(id: conversationId) {
452+
startPendingInviteObservationIfNeeded(draftConversationId: conversationId)
453+
}
454+
}
455+
456+
/// For draft conversations with an inviteTag, starts a background observation
457+
/// that watches for the matching non-draft conversation to appear in the DB.
458+
/// When it appears (written by SyncingManager's discoverNewConversations or
459+
/// the conversation stream), this transitions the state to the real conversation.
460+
private func startPendingInviteObservationIfNeeded(draftConversationId: String) {
461+
let inviteTag: String? = try? databaseReader.read { db in
462+
try DBConversation
463+
.filter(DBConversation.Columns.id == draftConversationId)
464+
.select(DBConversation.Columns.inviteTag)
465+
.fetchOne(db)
466+
}
467+
468+
guard let inviteTag, !inviteTag.isEmpty else { return }
469+
470+
Log.info("Starting pending invite observation for draft \(draftConversationId) with inviteTag")
471+
472+
observationTask?.cancel()
473+
let task = waitForJoinedConversation(inviteTag: inviteTag)
474+
observationTask = task
475+
476+
Task { [weak self] in
477+
guard let self else { return }
478+
do {
479+
let conversationId = try await task.value
480+
await self.handlePendingInviteResolved(
481+
conversationId: conversationId,
482+
draftConversationId: draftConversationId
483+
)
484+
} catch is CancellationError {
485+
// expected when navigating away or stopping
486+
} catch {
487+
Log.error("Pending invite observation failed: \(error)")
488+
}
489+
}
490+
}
491+
492+
private func handlePendingInviteResolved(conversationId: String, draftConversationId: String) {
493+
observationTask = nil
494+
guard case .ready(let result) = _state,
495+
result.conversationId == draftConversationId else {
496+
Log.debug("State no longer matches draft \(draftConversationId), skipping")
497+
return
498+
}
499+
Log.info("Pending invite resolved to conversation: \(conversationId)")
500+
cachedMessageWriter = nil
501+
QAEvent.emit(.conversation, "joined", ["id": conversationId])
502+
emitStateChange(.ready(ConversationReadyResult(
503+
conversationId: conversationId,
504+
origin: .joined
505+
)))
450506
}
451507

452508
private func handleValidate(inviteCode: String, previousResult: ConversationReadyResult?) async throws {
@@ -680,10 +736,12 @@ public actor ConversationStateMachine {
680736
throw ConversationStateMachineError.timedOut
681737
}
682738
}
739+
}
683740

741+
// MARK: - Cleanup
742+
743+
extension ConversationStateMachine {
684744
private func handleDelete() async throws {
685-
// For invites, we need the external conversation ID if available,
686-
// capture before changing state
687745
let conversationId: String? = switch _state {
688746
case .ready(let result):
689747
result.conversationId
@@ -693,18 +751,14 @@ public actor ConversationStateMachine {
693751

694752
emitStateChange(.deleting)
695753

696-
// Unregister error handler if we were in joining state
697754
await clearInviteJoinErrorHandler()
698755

699-
// Cancel observation tasks and stop accepting new messages
700-
// Note: currentTask is already cancelled by delete() - don't cancel ourselves!
701756
messageStreamContinuation?.finish()
702757
messageProcessingTask?.cancel()
703758
observationTask?.cancel()
704759
observationTask = nil
705760

706761
if let conversationId {
707-
// Get the inbox state to access the API client for unsubscribing
708762
let inboxReady = try await inboxStateManager.waitForInboxReadyResult()
709763

710764
try await cleanUp(
@@ -747,9 +801,6 @@ public actor ConversationStateMachine {
747801
client: any XMTPClientProvider,
748802
apiClient: any ConvosAPIClientProtocol
749803
) async throws {
750-
// nonisolated(unsafe) is used because XMTP types are not Sendable. This is safe
751-
// here because findConversation() is a one-shot operation, not a long-running
752-
// stream that could overlap with other XMTP operations.
753804
nonisolated(unsafe) let conversationsProvider = client.conversationsProvider
754805
let externalConversation = try await conversationsProvider.findConversation(conversationId: conversationId)
755806
try await externalConversation?.updateConsentState(state: .denied)

ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,15 @@ actor SyncingManager: SyncingManagerProtocol {
129129

130130
// MARK: - Initialization
131131

132+
private let databaseReader: any DatabaseReader
133+
132134
init(identityStore: any KeychainIdentityStoreProtocol,
133135
databaseWriter: any DatabaseWriter,
134136
databaseReader: any DatabaseReader,
135137
deviceRegistrationManager: (any DeviceRegistrationManagerProtocol)? = nil,
136138
notificationCenter: any UserNotificationCenterProtocol) {
137139
self.identityStore = identityStore
140+
self.databaseReader = databaseReader
138141
self.streamProcessor = StreamProcessor(
139142
identityStore: identityStore,
140143
databaseWriter: databaseWriter,
@@ -389,6 +392,11 @@ actor SyncingManager: SyncingManagerProtocol {
389392
Log.info("syncAllConversations completed, sync ready")
390393
QAEvent.emit(.sync, "completed")
391394

395+
// Discover any XMTP groups that the conversation stream missed.
396+
// This handles cases where the joiner was added to a group while
397+
// the inbox was paused, stopped, or the stream had a timeout.
398+
await discoverNewConversations(params: params)
399+
392400
// Process any join requests that may have been missed during stream startup.
393401
// This handles the race condition where a joiner sends a DM before the message
394402
// stream has fully subscribed to the XMTP network.
@@ -403,6 +411,50 @@ actor SyncingManager: SyncingManagerProtocol {
403411
}
404412
}
405413

414+
/// Lists all XMTP groups and processes any that are missing from the local database.
415+
///
416+
/// After syncAllConversations syncs the XMTP data layer, the local DB may still be
417+
/// missing groups that the conversation stream failed to deliver (e.g., stream timeout,
418+
/// inbox paused during approval, app backgrounded). This method provides a fallback
419+
/// by listing all groups and storing any that aren't already in the DB.
420+
private func discoverNewConversations(params: SyncClientParams) async {
421+
do {
422+
let groups = try params.client.conversationsProvider.listGroups(
423+
createdAfterNs: nil,
424+
createdBeforeNs: nil,
425+
lastActivityAfterNs: nil,
426+
lastActivityBeforeNs: nil,
427+
limit: nil,
428+
consentStates: params.consentStates,
429+
orderBy: .lastActivity
430+
)
431+
432+
let existingIds: Set<String> = try await databaseReader.read { db in
433+
let ids = try String.fetchAll(
434+
db,
435+
DBConversation.select(DBConversation.Columns.id)
436+
)
437+
return Set(ids)
438+
}
439+
440+
var discoveredCount: Int = 0
441+
for group in groups where !existingIds.contains(group.id) {
442+
do {
443+
try await streamProcessor.processConversation(group, params: params)
444+
discoveredCount += 1
445+
} catch {
446+
Log.error("Failed to process discovered conversation \(group.id): \(error)")
447+
}
448+
}
449+
450+
if discoveredCount > 0 {
451+
Log.info("Discovered \(discoveredCount) new conversations after sync")
452+
}
453+
} catch {
454+
Log.error("Failed to discover new conversations: \(error)")
455+
}
456+
}
457+
406458
/// Waits for both message and conversation streams to signal they're ready.
407459
/// Sets up stream readiness tracking by creating continuations that stream tasks will signal.
408460
/// Must be called BEFORE creating stream tasks to avoid race conditions.
@@ -539,8 +591,23 @@ actor SyncingManager: SyncingManagerProtocol {
539591
Log.debug("Waiting for streams to subscribe after resume...")
540592
await waitForStreamsToBeReady(messageStream: streams.messageStream, conversationStream: streams.conversationStream)
541593

594+
// Re-sync to pick up any changes that occurred while paused/backgrounded.
595+
// The conversation stream only delivers new groups created after subscription,
596+
// so groups added while paused would be missed without this.
597+
do {
598+
let syncStart = CFAbsoluteTimeGetCurrent()
599+
_ = try await params.client.conversationsProvider.syncAllConversations(consentStates: params.consentStates)
600+
let syncElapsed = String(format: "%.0f", (CFAbsoluteTimeGetCurrent() - syncStart) * 1000)
601+
Log.info("[PERF] sync.resume_conversations: \(syncElapsed)ms")
602+
} catch {
603+
Log.error("syncAllConversations on resume failed: \(error)")
604+
}
605+
542606
emitStateChange(.ready(params))
543607
Log.info("Sync resumed")
608+
609+
await discoverNewConversations(params: params)
610+
await processJoinRequestsAfterSync(params: params)
544611
}
545612

546613
private func handleStop() async throws {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Fix: Pending Invite Stuck After Navigation
2+
3+
## Problem
4+
5+
When a joiner scans an invite QR code and the inviter approves (adds them to the XMTP group), the joiner's device can get permanently stuck showing the conversation as "pending invite."
6+
7+
**User report:** Andy scanned an invite for "Balloon Animals." The inviter confirmed Andy was added. Andy's device showed the conversation stuck as pending for 45+ minutes.
8+
9+
## Root Cause
10+
11+
The joiner's device discovers new XMTP groups through exactly **two** mechanisms:
12+
1. **Conversation stream** — real-time stream from XMTP SDK
13+
2. **Message stream** — when a message arrives for a group conversation
14+
15+
`syncAllConversations()` only syncs the XMTP data layer (LibXMTP/Rust). It does **not** list groups and process new ones into the local GRDB database.
16+
17+
There are three gaps that caused the bug:
18+
19+
### Gap 1: No post-sync group discovery
20+
After `syncAllConversations` syncs from the network, there's no step that lists all XMTP groups and writes missing ones to the local DB. The app relies entirely on the conversation stream for discovery.
21+
22+
### Gap 2: Resume doesn't re-sync
23+
`SyncingManager.handleResume()` only restarts streams — it doesn't call `syncAllConversations`. If the joiner was added to a group while the inbox was paused/backgrounded, the conversation stream won't emit it (it only streams *newly created* groups, not pre-existing ones).
24+
25+
### Gap 3: ValueObservation cancelled on navigation
26+
`ConversationStateMachine.waitForJoinedConversation()` sets up a GRDB ValueObservation watching for a non-draft conversation with matching `inviteTag`. When the user navigates away, `handleStop()` cancels this observation. When the user returns to the conversation, it calls `useExisting()` which just sets state to `.ready` — it never restarts the observation.
27+
28+
**In Andy's case:** The inbox was stopped at 19:34:38 (after initial sync completed but before the inviter processed the join request). When it resumed at 19:42:25, only streams were restarted. The group had already been created on the network, so the conversation stream didn't emit it. No fallback mechanism existed to discover it.
29+
30+
## Fixes
31+
32+
### Fix 1: Post-sync group discovery in SyncingManager
33+
34+
After `syncAllConversations` completes, list all XMTP groups and process any that don't exist in the local DB.
35+
36+
**File:** `ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift`
37+
38+
In `handleSyncComplete`, after calling `processJoinRequestsAfterSync`:
39+
40+
```
41+
1. List all XMTP groups via client.conversationsProvider.listGroups(consentStates: params.consentStates)
42+
2. For each group, check if a DBConversation exists with that group ID
43+
3. If not, call streamProcessor.processConversation(group, params:) to store it
44+
```
45+
46+
This is the primary fix — it provides a robust fallback regardless of how the stream missed the event.
47+
48+
### Fix 2: Re-sync on resume
49+
50+
When `handleResume()` is called (foreground, network reconnect), call `syncAllConversations` followed by the group discovery step from Fix 1.
51+
52+
**File:** `ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift`
53+
54+
Modify `handleResume()` to:
55+
1. Restart streams (existing behavior)
56+
2. Call `syncAllConversations`
57+
3. Run post-sync group discovery (Fix 1)
58+
4. Run `processJoinRequestsAfterSync` (existing behavior)
59+
60+
### Fix 3: Restart join observation for pending drafts
61+
62+
When `ConversationStateMachine` receives `useExisting` for a draft conversation that has a pending invite (draft ID + inviteTag), restart the `waitForJoinedConversation` observation instead of immediately transitioning to `.ready`.
63+
64+
**File:** `ConvosCore/Sources/ConvosCore/Inboxes/ConversationStateMachine.swift`
65+
66+
Modify `handleUseExisting(conversationId:)`:
67+
1. If the conversationId starts with `draft-`, look up the DBConversation
68+
2. If it has an inviteTag and hasn't joined yet, start `waitForJoinedConversation(inviteTag:)` observation
69+
3. Emit `.ready` with the draft ID so the UI still renders, but keep the observation running
70+
4. When the observation fires (non-draft conversation appears), update the state with the real conversation ID
71+
72+
This ensures reopening a pending conversation re-arms the detection mechanism.
73+
74+
## Files to Modify
75+
76+
| File | Change |
77+
|------|--------|
78+
| `SyncingManager.swift` | Add `discoverNewConversations(params:)` after sync; call on resume |
79+
| `ConversationStateMachine.swift` | Restart join observation for draft conversations in `handleUseExisting` |
80+
| `StreamProcessor.swift` | Possibly extract `shouldProcessConversation` check for reuse |
81+
82+
## Testing
83+
84+
### Unit Tests
85+
- `SyncingManager`: After sync completes, new groups are processed into DB
86+
- `ConversationStateMachine`: `useExisting` with a draft ID + inviteTag starts observation
87+
- `ConversationStateMachine`: Observation fires when matching non-draft conversation appears
88+
89+
### QA Test
90+
See `qa/tests/XX-pending-invite-recovery.md` — covers the scenario where the joiner navigates away during the pending state and the invite is approved while they're in another conversation.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Test: Pending Invite Recovery After Navigation
2+
3+
Verify that when a joiner navigates away from a pending invite conversation, the invite is still detected and the conversation transitions to active after the inviter approves.
4+
5+
## Prerequisites
6+
7+
- The app is running and past onboarding on the simulator (this is the joiner's device).
8+
- The `convos` CLI is available for creating the inviter identity and processing join requests.
9+
- Two separate identities are needed: the inviter (CLI) and the joiner (simulator).
10+
11+
## Setup
12+
13+
1. Using the CLI, create a new conversation as the inviter and generate an invite link.
14+
2. Note the invite URL for the joiner to scan.
15+
16+
## Steps
17+
18+
### Join via invite and navigate away
19+
20+
1. Open the invite URL in the simulator as a deep link.
21+
2. The app should show the new conversation view with a "Verifying" state or QR code.
22+
3. Wait a few seconds for the join request DM to be sent (check logs for `join_request_sent` event).
23+
4. Navigate back to the conversations list (tap back or swipe).
24+
5. Open a different conversation (or create a new one) so the pending invite's inbox is no longer the active view.
25+
26+
### Approve the invite while joiner is away
27+
28+
6. Using the CLI on the inviter's identity, process pending join requests to approve the joiner.
29+
7. Verify via CLI that the joiner was added to the conversation (member count should be 2).
30+
31+
### Verify recovery
32+
33+
8. Navigate back to the conversations list.
34+
9. The previously pending conversation should now appear as a normal (non-pending) conversation with the correct name.
35+
10. Tap on the conversation to open it.
36+
11. The conversation should be fully functional — the bottom bar should be enabled, messages should be sendable.
37+
38+
### Verify via background/foreground cycle (additional recovery path)
39+
40+
12. If step 9 still shows pending, background the app and bring it back to foreground.
41+
13. After foregrounding, the conversation should transition to active within a few seconds.
42+
43+
## Teardown
44+
45+
Explode both conversations (the test conversation and any helper conversation) using the CLI or app UI.
46+
47+
## Pass/Fail Criteria
48+
49+
- [ ] Join request is sent successfully (log event `invite.join_request_sent`)
50+
- [ ] Navigating away from the pending conversation does not cause errors
51+
- [ ] After the inviter approves, the joiner's conversation transitions from pending to active
52+
- [ ] The conversation is fully functional after recovery (messages can be sent)
53+
- [ ] The transition happens without requiring the user to re-scan the invite code
54+
- [ ] The recovery works even if the app was backgrounded during approval
55+
56+
## Accessibility Improvements Needed
57+
58+
- None identified yet — this test primarily validates backend sync behavior rather than UI elements.

0 commit comments

Comments
 (0)