crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 4d6481378b8ed6a353a5a86dbcec9e543d0215bf
parent e15e4042b3e7d02b47475a82843345224a076444
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 02:27:52 +0900

Delete the invite Ping when leaving a shared game

An invite is backed by a durable .invite Ping in the inviter's friend
zone; the local card, badge, and banner are all projections of it. The
Ping's only cleanup, consumeStaleInvites, deletes it once the game is in
the local library — but leaveShare hard-deletes that GameEntity, so a
left game's Ping has no surviving trigger. It then reads as a fresh,
unanswered invite: the card is rebuilt and the banner re-fires on the
next cold start, and on any sibling device that re-syncs it. (Revocation
is unaffected — it flags the GameEntity access-revoked rather than
deleting it, so consumeStaleInvites still fires.)

This commit has leaveShare delete the game's .invite Ping(s) before
removing the local row. deleteInvitePingsAfterLeave scans the friend
zones in both scopes, matches Pings by gameID, and deletes them through
the CKSyncEngine path so the removal is durable and retried. Deleting
the source record clears the invite on every device; a later re-invite
is a new Ping with its own record name and still surfaces normally,
which a gameID-keyed local suppression would have wrongly hidden.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Sync/CloudQuery.swift | 44++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/ShareController.swift | 8++++++++
2 files changed, 52 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -233,6 +233,50 @@ extension SyncEngine { return pings.count } + /// Deletes the `.invite` Ping(s) for `gameID` from the user's friend zones. + /// Called when leaving a shared game: the invite Ping is durable and its + /// only other cleanup, `consumeStaleInvites`, keys off a local `GameEntity` + /// that leaving has just removed — so without this the invite resurrects as + /// a fresh card on the next cold start (and on any sibling device that + /// re-syncs it). Deleting the source record clears it everywhere; a later + /// re-invite is a new Ping with its own record name and still surfaces. + /// Best-effort: a per-zone query failure is traced and skipped so leaving + /// still completes. Scans both scopes because invite Pings live in either a + /// private or shared friend zone depending on how the pair befriended. + func deleteInvitePingsAfterLeave(forGameID gameID: UUID) async { + for (scopeValue, database) in [ + (Int16(0), container.privateCloudDatabase), + (Int16(1), container.sharedCloudDatabase) + ] { + for zoneID in friendZoneIDs(forScope: scopeValue) { + let records: [CKRecord] + do { + records = try await queryRecords( + type: "Ping", + database: database, + zoneID: zoneID, + predicate: NSPredicate(format: "kind == %@", PingKind.invite.rawValue), + desiredKeys: ["authorID", "kind"] + ) + } catch { + await trace( + "leave invite cleanup: zone \(zoneID.zoneName) query failed: " + + "\(error.localizedDescription)" + ) + continue + } + for record in records { + guard let ping = Ping.parseRecord(record), ping.gameID == gameID else { continue } + deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue) + await trace( + "leave invite cleanup: deleting invite ping \(ping.recordName) " + + "for \(gameID.uuidString)" + ) + } + } + } + } + /// Lightweight background read for session presence. This intentionally /// reads only Player records; Ping records are durable bootstrap state, /// not part of the live/background notification path. diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -328,6 +328,14 @@ final class ShareController { // Already gone — proceed to clean up local state. } + // Delete the invite Ping that brought us in, if it's still around. + // It's durable and its usual cleanup (`consumeStaleInvites`) keys off + // the local GameEntity we're about to remove, so leaving it behind + // lets the invite resurrect on the next cold start and on sibling + // devices. Done before the local delete so a query failure can't strand + // a half-left game. + await syncEngine.deleteInvitePingsAfterLeave(forGameID: gameID) + // Record the leave as a durable per-user fact so the user's other // devices hard-delete this game too. Without it, a sibling sees only // the shared-zone deletion — indistinguishable from the owner