commit 4e826c57fc1461dcee655f8fc772046127de01be
parent 826437414d77839f2466328f8c4d55f6f21a4f7e
Author: Michael Camilleri <[email protected]>
Date: Sun, 14 Jun 2026 06:25:12 +0900
Notify for a game invite once (and not over the Game List)
An inbound .invite Ping re-surfaced its local notification on every app
open until the invite was accepted or declined, and showed as a
foreground banner even while the user sat on the Game List — where the
invite already appears in the 'Invited' section.
This had two causes:
- The only presentation dedup was the in-memory claimedPingRecordNames
set, which resets each launch. A still-pending invite's Ping is
re-fetched on every cold start, and since the game isn't
joined/declined, nothing consumes it, so the notification re-fired.
applyInvitePings now reports the invites whose durable InviteEntity
row it created for the first time (i.e. that just synced from the
server), and presentPings only notifies for those. A re-fetched invite
already has its row, so it's skipped — the Invited row and badge are
unaffected.
- The foreground presentation gate only suppressed the banner for a
puzzle the user was actively viewing, which an unjoined invite never
matches. It now also drops the invite banner when no puzzle is active
(the user is on the Game List), while still presenting it when the
user is inside a different puzzle and wouldn't see the row.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -157,6 +157,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
completionHandler([.banner, .list, .sound])
return
}
+ // An invite banner is redundant on the Game List: the invite already
+ // shows in the "Invited" section there. Suppress it while no puzzle is
+ // active (the user is on the list), but still present it when the user
+ // is inside a different puzzle and wouldn't see that row.
+ let isInvite = (userInfo["pingKind"] as? String) == PingKind.invite.rawValue
+ if isInvite, NotificationState.activePuzzleID() == nil {
+ completionHandler([])
+ return
+ }
if NotificationState.isSuppressed(gameID: gameID) {
completionHandler([])
} else {
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -154,16 +154,23 @@ final class InviteCoordinator {
/// from blocked friends, games already joined, and any `pingRecordName`
/// already seen (a declined row is a tombstone that prevents
/// resurrection).
- private func applyInvitePings(_ pings: [Ping]) {
+ /// Returns the `pingRecordName`s of invites whose durable row was created
+ /// for the first time by this call — i.e. invites that have just synced
+ /// from the server. The caller uses this to notify exactly once: a pending
+ /// invite's Ping is re-fetched on every cold start, but its row already
+ /// exists by then, so it is absent from this set and isn't re-surfaced.
+ @discardableResult
+ private func applyInvitePings(_ pings: [Ping]) -> Set<String> {
let invites = pings.filter {
$0.kind == .invite &&
$0.authorID != identity.currentID &&
$0.addressee == identity.currentID
}
- guard !invites.isEmpty else { return }
+ guard !invites.isEmpty else { return [] }
let ctx = persistence.container.newBackgroundContext()
- ctx.performAndWait {
+ return ctx.performAndWait {
+ var insertedPingRecordNames: Set<String> = []
for ping in invites {
guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue }
@@ -181,6 +188,7 @@ final class InviteCoordinator {
invite.pingRecordName = ping.recordName
invite.status = "pending"
invite.createdAt = Date()
+ insertedPingRecordNames.insert(ping.recordName)
}
// GC: a pending invite whose game now exists locally was joined by
@@ -200,11 +208,15 @@ final class InviteCoordinator {
do {
try ctx.save()
} catch {
+ // The rows didn't persist, so treat none as "freshly
+ // recorded" — a later fetch will re-create and notify.
+ insertedPingRecordNames.removeAll()
Task { @MainActor [weak self] in
self?.eventLog.note("InviteCoordinator: applyInvitePings save failed — \(error)", level: "error")
}
}
}
+ return insertedPingRecordNames
}
}
@@ -498,7 +510,7 @@ final class InviteCoordinator {
guard !claimed.isEmpty else { return }
let pings = await consumeStaleInvites(claimed)
guard !pings.isEmpty else { return }
- applyInvitePings(pings)
+ let newlyInvited = applyInvitePings(pings)
// Reflect any newly-stored pending invite in the app-icon badge now —
// before the notification-authorization guard — so the badge updates
// even when the banner is suppressed or unauthorized.
@@ -553,6 +565,16 @@ final class InviteCoordinator {
await consumeIfDirected()
continue
}
+ // Notify for an invite only the first time it syncs from the
+ // server (its durable row was just created). A still-pending
+ // invite's Ping is re-fetched on every cold start; without this
+ // gate the banner would repeat on every app open until the invite
+ // is accepted or declined. The Invited row itself is unaffected —
+ // `applyInvitePings` keeps it regardless.
+ if ping.kind == .invite, !newlyInvited.contains(ping.recordName) {
+ syncMonitor.note("ping(invite): already recorded, not re-notifying for \(ping.gameID.uuidString)")
+ continue
+ }
// Invite banners are user-toggleable; the invite row itself still
// lands in the Invited section through `applyInvitePings`.
if ping.kind == .invite, !preferences.notifiesInvites {