crossmate

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

commit bdddd25f07bddb25faa815023f7a44322af2c022
parent 738a9a685f9ad0ba58b3eb8693d213c7832c854f
Author: Michael Camilleri <[email protected]>
Date:   Wed, 27 May 2026 14:29:33 +0900

Consume stale .invite Pings before presenting them

An .invite Ping can survive in a friend's zone after the invitation is no
longer actionable — either the game is already in the local library (joined
here or on a sibling device, or arrived via zone discovery before the Accept
button was tapped) or the inviter has since been blocked.  presentPings exempts
.invite from the directed-ping consume-on-show path, applyInvitePings just
short-circuits without creating an InviteEntity, and the fast-path dedup state
(claimedPingRecordNames, seenPingRecords, pingPushCheckpoints) is all in-memory
— so every cold start re-fetched the orphan and re-fired a local notification
for a game the user had already joined, with no matching 'Invited' row to clear
it from.

This commit pulls the staleness rule upstream into a new consumeStaleInvites
step that runs once between claimPingsForHandling and applyInvitePings. For
each .invite whose game is already local or whose sender is blocked,
consumeStaleInvites deletes the Ping via friendController.deleteInvitePing and
drops it from the pipeline; both applyInvitePings and the notification loop
then see only live invites.

Given this, applyInvitePings sheds the isBlocked and gameExists short-circuits
that were duplicating this check from inside its loop; the pingRecordName dedup
and the post-loop GC of pending InviteEntities whose game now exists are
unchanged.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
1 file changed, 58 insertions(+), 15 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1643,18 +1643,6 @@ final class AppServices { for ping in invites { guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue } - let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - blockedReq.predicate = NSPredicate( - format: "authorID == %@ AND isBlocked == YES", ping.authorID - ) - blockedReq.fetchLimit = 1 - if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 { continue } - - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg) - gameReq.fetchLimit = 1 - if ((try? ctx.count(for: gameReq)) ?? 0) > 0 { continue } - let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName) dupReq.fetchLimit = 1 @@ -1831,8 +1819,9 @@ final class AppServices { try? await shareController.leaveShare(gameID: gid) } - // Drop their pending invites. `applyInvitePings` skips blocked - // senders, so these won't be recreated by a re-fetched Ping. + // Drop their pending invites. Future inbound `.invite` Pings from a + // blocked sender are caught by `consumeStaleInvites`, which deletes + // the Ping so it doesn't re-fire across cold starts. let vctx = persistence.viewContext let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID) @@ -1840,8 +1829,62 @@ final class AppServices { if vctx.hasChanges { try? vctx.save() } } + /// Deletes `.invite` Pings that are no longer actionable on this device — + /// the game is already in the local library (joined here or on a sibling), + /// or the inviter is blocked — and returns the surviving pings. Running + /// this upstream of both `applyInvitePings` and the notification loop + /// keeps the staleness rule in one place; without it, an orphaned invite + /// Ping re-fires a notification on every cold start because the in-memory + /// dedup caches reset. + private func consumeStaleInvites(_ pings: [Ping]) async -> [Ping] { + let candidates = pings.filter { + $0.kind == .invite && + $0.authorID != identity.currentID && + $0.addressee == identity.currentID + } + guard !candidates.isEmpty else { return pings } + + let ctx = persistence.container.newBackgroundContext() + let staleNames: Set<String> = ctx.performAndWait { + var names: Set<String> = [] + for ping in candidates { + let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + blockedReq.predicate = NSPredicate( + format: "authorID == %@ AND isBlocked == YES", ping.authorID + ) + blockedReq.fetchLimit = 1 + if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 { + names.insert(ping.recordName) + continue + } + + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg) + gameReq.fetchLimit = 1 + if ((try? ctx.count(for: gameReq)) ?? 0) > 0 { + names.insert(ping.recordName) + } + } + return names + } + guard !staleNames.isEmpty else { return pings } + + for ping in candidates where staleNames.contains(ping.recordName) { + await friendController.deleteInvitePing( + fromFriendAuthorID: ping.authorID, + recordName: ping.recordName + ) + syncMonitor.note( + "ping(invite): consumed stale invite \(ping.recordName) for \(ping.gameID.uuidString)" + ) + } + return pings.filter { !staleNames.contains($0.recordName) } + } + private func presentPings(_ pings: [Ping]) async { - let pings = claimPingsForHandling(pings) + let claimed = claimPingsForHandling(pings) + guard !claimed.isEmpty else { return } + let pings = await consumeStaleInvites(claimed) guard !pings.isEmpty else { return } applyInvitePings(pings) // `.friend` is the friendship-bootstrap handshake and `.hail` is