commit 2f1e113b928f99a777d93d195b106f2f7c0746a1
parent 6bb977fcc28fce2718aa571882edb7d5fa84869b
Author: Michael Camilleri <[email protected]>
Date: Mon, 8 Jun 2026 06:42:29 +0900
Consume declined invite Pings across devices
This commit makes declining an invite consume the source .invite Ping,
matching the existing accept path and the intended model that Pings are
not permanent messages. The declining device keeps its local declined
tombstone so a delayed CloudKit delete cannot immediately resurrect the
same invite, but it now also deletes the friend-zone Ping so sibling
devices learn that the invitation has been handled.
Ping deletion callbacks now include the deleted record name as well as
the game ID. That lets sibling devices remove only the pending
InviteEntity backed by the consumed Ping, refresh the badge, and still
dismiss any delivered notification for the affected game without
conflating other Ping kinds for the same puzzle.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 70 insertions(+), 24 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -598,10 +598,12 @@ final class AppServices {
// A sibling device consumed (deleted) a directed ping; withdraw any
// copy of that game's notification we delivered before the deletion
- // reached us.
- await syncEngine.setOnPingDeleted { [weak self] gameIDs in
+ // reached us, and clear any durable invite row backed by that Ping.
+ await syncEngine.setOnPingDeleted { [weak self] pings in
guard let self else { return }
- for gameID in gameIDs {
+ try? self.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName }))
+ await self.refreshAppBadge()
+ for gameID in Set(pings.map { $0.gameID }) {
await self.dismissDeliveredNotifications(
for: gameID,
publishAccountSeen: false
@@ -2484,6 +2486,26 @@ final class AppServices {
}
}
+ /// Drops pending invite rows whose source Ping was consumed elsewhere on
+ /// this account. Matching by record name avoids conflating invite Pings
+ /// with other Ping kinds for the same game.
+ func removePendingInvites(forPingRecordNames recordNames: Set<String>) throws {
+ guard !recordNames.isEmpty else { return }
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ req.predicate = NSPredicate(
+ format: "pingRecordName IN %@ AND status == %@",
+ Array(recordNames),
+ "pending"
+ )
+ let invites = try ctx.fetch(req)
+ guard !invites.isEmpty else { return }
+ for invite in invites { ctx.delete(invite) }
+ if ctx.hasChanges {
+ try ctx.save()
+ }
+ }
+
/// Accepts a pending game invite: fetches the share metadata, joins via
/// the existing share-accept path, then drops the local `InviteEntity`
/// (the game now represents it). If CloudKit says the share URL no longer
@@ -2556,9 +2578,11 @@ final class AppServices {
/// Declines a pending game invite: marks the durable `InviteEntity` rows for
/// `gameID` as a `"declined"` tombstone (which prevents the invite from
- /// resurrecting if the Ping re-arrives) and refreshes the badge. Surfaced via
- /// `\.declineInvite`; the AppServices entry point keeps invite mutation and
- /// the badge refresh in one place, mirroring `acceptInvite`.
+ /// resurrecting locally if CloudKit deletion is delayed), consumes the
+ /// source invite Ping so sibling devices clear their rows, and refreshes
+ /// the badge. Surfaced via `\.declineInvite`; the AppServices entry point
+ /// keeps invite mutation and the badge refresh in one place, mirroring
+ /// `acceptInvite`.
func declineInvite(gameID: UUID) async throws {
let ctx = persistence.viewContext
let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
@@ -2567,11 +2591,25 @@ final class AppServices {
)
let invites = try ctx.fetch(req)
guard !invites.isEmpty else { return }
- for invite in invites { invite.status = "declined" }
+ let pingsToDelete = invites.compactMap { invite -> (String, String)? in
+ guard let inviterAuthorID = invite.inviterAuthorID,
+ let pingRecordName = invite.pingRecordName
+ else { return nil }
+ return (inviterAuthorID, pingRecordName)
+ }
+ for invite in invites {
+ invite.status = "declined"
+ }
if ctx.hasChanges {
try ctx.save()
await refreshAppBadge()
}
+ for (inviterAuthorID, pingRecordName) in pingsToDelete {
+ await friendController.deleteInvitePing(
+ fromFriendAuthorID: inviterAuthorID,
+ recordName: pingRecordName
+ )
+ }
}
/// Blocks a collaborator: marks the friendship blocked and tears down the
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -140,12 +140,15 @@ extension SyncEngine {
enqueueJournalUpload(gameID: id, authorID: localAuthorID)
}
}
- let pingDeletedGameIDs = Set(deletions.compactMap { deletion -> UUID? in
- deletion.0.recordName.hasPrefix("ping-")
- ? gameID(fromRecordName: deletion.0.recordName) : nil
- })
- if let onPingDeleted, !pingDeletedGameIDs.isEmpty {
- await onPingDeleted(pingDeletedGameIDs)
+ let deletedPings = deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in
+ let recordName = deletion.0.recordName
+ guard recordName.hasPrefix("ping-"),
+ let gameID = gameID(fromRecordName: recordName)
+ else { return nil }
+ return (recordName, gameID)
+ }
+ if let onPingDeleted, !deletedPings.isEmpty {
+ await onPingDeleted(deletedPings)
}
if !effects.rosterRelevant.isEmpty {
NotificationCenter.default.post(
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -118,10 +118,10 @@ actor SyncEngine {
/// the user joined the game here or on a sibling device. Drives cleanup
/// of the now-redundant pending invite row.
private var onGameJoined: (@MainActor @Sendable (UUID) async -> Void)?
- /// Fires with the game IDs whose Ping record(s) were just deleted on the
- /// server (a sibling device consumed a directed ping). Drives cross-device
- /// withdrawal of the notification this device may have shown for it.
- var onPingDeleted: (@MainActor @Sendable (Set<UUID>) async -> Void)?
+ /// Fires with Ping records that were just deleted on the server (a sibling
+ /// device consumed a directed ping). Drives cross-device withdrawal of the
+ /// notification and any local durable invite row this device may hold.
+ var onPingDeleted: (@MainActor @Sendable ([(recordName: String, gameID: UUID)]) async -> Void)?
/// Fires with (gameID, readAt) pairs lifted from inbound Player records
/// whose authorID matches the local user. A sibling device has recorded
/// the account's read horizon; active sessions may move it into the near
@@ -204,7 +204,9 @@ actor SyncEngine {
onGameJoined = cb
}
- func setOnPingDeleted(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
+ func setOnPingDeleted(
+ _ cb: @MainActor @Sendable @escaping ([(recordName: String, gameID: UUID)]) async -> Void
+ ) {
onPingDeleted = cb
}
@@ -1424,12 +1426,15 @@ actor SyncEngine {
enqueueJournalUpload(gameID: id, authorID: localAuthorID)
}
}
- let pingDeletedGameIDs = Set(event.deletions.compactMap { deletion -> UUID? in
- deletion.recordID.recordName.hasPrefix("ping-")
- ? gameID(fromRecordName: deletion.recordID.recordName) : nil
- })
- if let onPingDeleted, !pingDeletedGameIDs.isEmpty {
- await onPingDeleted(pingDeletedGameIDs)
+ let deletedPings = event.deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in
+ let recordName = deletion.recordID.recordName
+ guard recordName.hasPrefix("ping-"),
+ let gameID = gameID(fromRecordName: recordName)
+ else { return nil }
+ return (recordName, gameID)
+ }
+ if let onPingDeleted, !deletedPings.isEmpty {
+ await onPingDeleted(deletedPings)
}
if !effects.rosterRelevant.isEmpty {
NotificationCenter.default.post(