crossmate

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

commit bf9bbd051636d25c0635b93fae6ecd938b623bfd
parent 13ddb51c1bf5430cdf6cfd3217d373bb289a4361
Author: Michael Camilleri <[email protected]>
Date:   Sat,  6 Jun 2026 22:57:55 +0900

Count pending invites toward the app-icon badge

A game invitation produced a notification but never bumped the app icon
badge: the invite's local notification set no content.badge, and
refreshAppBadge derived the count solely from unread other-player
moves in games already joined. A pending invite has no moves, so it
counted for nothing.

This commit adds a dedicated, authoritative pending-invite set to
BadgeState. Unlike the unread/seen horizon ledger, an invite is binary,
so the app overwrites the set wholesale from Core Data ground truth on
every refreshAppBadge and the Notification Service Extension unions it
into its count.  Consequently, a moves push landing while the app is
suspended no longer re-stamps the badge to the moves-only total and
drops a still-pending invite. The invite-gameID and unread-moves-gameID
sets are disjoint (a pending InviteEntity is GC'd once its GameEntity
exists), so the union is exact.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 3+++
MCrossmate/Persistence/GameStore.swift | 23+++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 41+++++++++++++++++++++++++++++++++++++++--
MCrossmate/Sync/SyncEngine.swift | 2++
MCrossmate/Views/GameListView.swift | 11++++++-----
MNotificationService/NotificationService.swift | 11+++++++----
MShared/NotificationState.swift | 25+++++++++++++++++++++++++
MTests/Unit/NotificationStateTests.swift | 46++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 151 insertions(+), 11 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -51,6 +51,9 @@ struct CrossmateApp: App { .environment(\.acceptInvite, { shareURL, pingRecordName in try await services.acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) }) + .environment(\.declineInvite, { gameID in + try await services.declineInvite(gameID: gameID) + }) .environment(\.blockFriend, { friendAuthorID in await services.blockFriend(authorID: friendAuthorID) }) diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -505,6 +505,29 @@ final class GameStore { return result } + /// Games this account has a pending (un-acted) invite to, excluding invites + /// from blocked collaborators — the same set the library's "Invited" section + /// shows (`GameListView` filters blocked inviters at display time). The app + /// publishes these into `BadgeState` so a pending invite counts toward the + /// app-icon badge. A pending `InviteEntity` is dropped once its `GameEntity` + /// exists, so this set is disjoint from the unread-other-moves set. + func pendingInviteGameIDs() -> Set<UUID> { + let blockedRequest = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + blockedRequest.predicate = NSPredicate(format: "isBlocked == YES") + blockedRequest.propertiesToFetch = ["authorID"] + let blocked = Set(((try? context.fetch(blockedRequest)) ?? []).compactMap(\.authorID)) + + let request = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + request.predicate = NSPredicate(format: "status == %@", "pending") + request.propertiesToFetch = ["gameID", "inviterAuthorID"] + let rows = (try? context.fetch(request)) ?? [] + return Set(rows.compactMap { invite in + guard let id = invite.gameID else { return nil } + if let inviter = invite.inviterAuthorID, blocked.contains(inviter) { return nil } + return id + }) + } + private var unreadOtherMovesPredicate: NSPredicate { NSPredicate( format: "(databaseScope == 1 OR ckShareRecordName != nil) " diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -560,6 +560,8 @@ final class AppServices { // ping is next fetched. do { try self.removePendingInvite(forGameID: gameID) + // The pending invite (if any) is gone; drop it from the badge. + await self.refreshAppBadge() } catch { self.announcements.post(Announcement( id: "remove-pending-invite-error-\(gameID.uuidString)", @@ -2515,6 +2517,27 @@ final class AppServices { } if ctx.hasChanges { try ctx.save() + await refreshAppBadge() + } + } + + /// 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`. + func declineInvite(gameID: UUID) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate( + format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending" + ) + let invites = try ctx.fetch(req) + guard !invites.isEmpty else { return } + for invite in invites { invite.status = "declined" } + if ctx.hasChanges { + try ctx.save() + await refreshAppBadge() } } @@ -2571,7 +2594,10 @@ final class AppServices { let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID) for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } - if vctx.hasChanges { try? vctx.save() } + if vctx.hasChanges { + try? vctx.save() + await refreshAppBadge() + } } /// Deletes `.invite` Pings that are no longer actionable on this device — @@ -2632,6 +2658,10 @@ final class AppServices { let pings = await consumeStaleInvites(claimed) guard !pings.isEmpty else { return } 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. + await refreshAppBadge() // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail` // are legacy live-notification/bootstrap kinds; APNs and Game-record // engagement creds own those jobs now. System pings do not require @@ -3046,7 +3076,14 @@ final class AppServices { func refreshAppBadge() async { let coreDataUnread = store.unreadOtherMovesGameTimes() BadgeState.seedUnread(coreDataUnread) - let merged = BadgeState.unreadGameIDs().union(coreDataUnread.keys) + // Pending invites are binary (not a read horizon), so publish them as a + // dedicated authoritative set the NSE unions into its count. Disjoint + // from the unread-moves set, so the union below never double-counts. + let pendingInvites = store.pendingInviteGameIDs() + BadgeState.setPendingInvites(pendingInvites) + let merged = BadgeState.unreadGameIDs() + .union(coreDataUnread.keys) + .union(pendingInvites) do { try await UNUserNotificationCenter.current().setBadgeCount(merged.count) } catch { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -10,6 +10,8 @@ extension EnvironmentValues { @Entry var inviteFriend: ((UUID, String) async throws -> Void)? = nil /// `(shareURL, pingRecordName)` — accepts a pending game invite. @Entry var acceptInvite: ((String, String) async throws -> Void)? = nil + /// `(gameID)` — declines a pending game invite (tombstone + badge refresh). + @Entry var declineInvite: ((UUID) async throws -> Void)? = nil /// `(friendAuthorID)` — blocks a collaborator: suppress future invites, /// leave their shared games, tear down the friend zone. @Entry var blockFriend: ((String) async -> Void)? = nil diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -32,6 +32,7 @@ struct GameListView: View { private var blockedFriends: FetchedResults<FriendEntity> @Environment(\.acceptInvite) private var acceptInvite + @Environment(\.declineInvite) private var declineInvite @Environment(\.blockFriend) private var blockFriend @Environment(\.sendResignPings) private var sendResignPings @Environment(AnnouncementCenter.self) private var announcements @@ -468,7 +469,7 @@ struct GameListView: View { private func inviteMenu(for invite: InviteEntity) -> some View { Menu { - Button { decline(invite) } label: { + Button { Task { await decline(invite) } } label: { Label("Decline", systemImage: "xmark") } Button(role: .destructive) { blockTarget = invite } label: { @@ -506,7 +507,7 @@ struct GameListView: View { inviteMenu(for: invite) } .swipeActions(edge: .trailing) { - Button("Decline") { decline(invite) } + Button("Decline") { Task { await decline(invite) } } .tint(.gray) Button("Block", role: .destructive) { blockTarget = invite } } @@ -542,10 +543,10 @@ struct GameListView: View { /// resign, delete) — a fresh failure replaces the prior one. private static let destructiveActionErrorID = "game-list-destructive-action-error" - private func decline(_ invite: InviteEntity) { - invite.status = "declined" + private func decline(_ invite: InviteEntity) async { + guard let declineInvite, let gameID = invite.gameID else { return } do { - try viewContext.save() + try await declineInvite(gameID) } catch { announcements.post(Announcement( id: Self.destructiveActionErrorID, diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -73,11 +73,14 @@ final class NotificationService: UNNotificationServiceExtension { } if let gameID, marksUnread { - let count = BadgeState.markUnread(gameID: gameID) - bestAttemptContent.badge = NSNumber(value: count) - } else { - bestAttemptContent.badge = NSNumber(value: BadgeState.unreadGameIDs().count) + BadgeState.markUnread(gameID: gameID) } + // Fold in pending invites the app published to the App Group: a moves + // push must not re-stamp the badge to the moves-only count and drop a + // still-pending invite. The two sets are disjoint, so the union is exact. + let count = BadgeState.unreadGameIDs() + .union(BadgeState.pendingInviteGameIDs()).count + bestAttemptContent.badge = NSNumber(value: count) contentHandler(bestAttemptContent) } diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -191,6 +191,7 @@ enum BadgeState { private static let ledgerKey = "badge.ledger.v2" private static let legacyLedgerKey = "badge.ledger.v1" private static let legacyUnreadKey = "badge.unreadGameIDs" + private static let pendingInvitesKey = "badge.pendingInvites.v1" private struct Entry: Codable, Equatable { var unreadAt: Date? = nil @@ -268,6 +269,29 @@ enum BadgeState { saveLedger(ledger) } + /// Authoritative set of games this account has been invited to but not yet + /// joined. Unlike the horizon ledger, a pending invite is binary — it is + /// pending or it isn't — so the app overwrites this set wholesale from Core + /// Data ground truth on every `refreshAppBadge`. The Notification Service + /// Extension, which can't reach Core Data, unions this into its badge count + /// so a moves push landing while the app is suspended doesn't drop a still + /// pending invite from the total. + static func setPendingInvites(_ ids: Set<UUID>) { + guard let defaults else { return } + if ids.isEmpty { + defaults.removeObject(forKey: pendingInvitesKey) + return + } + defaults.set(ids.map(\.uuidString), forKey: pendingInvitesKey) + } + + static func pendingInviteGameIDs() -> Set<UUID> { + guard let defaults, + let raw = defaults.array(forKey: pendingInvitesKey) as? [String] + else { return [] } + return Set(raw.compactMap(UUID.init(uuidString:))) + } + /// Clears the entire ledger (and any legacy stores). Used by the /// diagnostics "reset all data" path, which deletes every game at once. static func reset() { @@ -275,6 +299,7 @@ enum BadgeState { defaults.removeObject(forKey: ledgerKey) defaults.removeObject(forKey: legacyLedgerKey) defaults.removeObject(forKey: legacyUnreadKey) + defaults.removeObject(forKey: pendingInvitesKey) } private static func loadLedger() -> [String: Entry] { diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -126,4 +126,50 @@ struct NotificationStateTests { // A re-seed of a forgotten game (e.g. a stale push) starts clean. #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1) } + + @Test("Pending invites round-trip and overwrite wholesale") + func pendingInvitesOverwrite() { + let first = UUID() + let second = UUID() + + BadgeState.setPendingInvites([first]) + #expect(BadgeState.pendingInviteGameIDs() == Set([first])) + + // The app republishes ground truth each refresh, so a new set replaces + // the old one rather than unioning. + BadgeState.setPendingInvites([second]) + #expect(BadgeState.pendingInviteGameIDs() == Set([second])) + + // An empty set clears the store entirely (e.g. the last invite accepted). + BadgeState.setPendingInvites([]) + #expect(BadgeState.pendingInviteGameIDs().isEmpty) + } + + @Test("Pending invites are independent of the unread-moves ledger") + func pendingInvitesIndependentOfLedger() { + let move = UUID() + let invite = UUID() + let at = Date(timeIntervalSince1970: 50_000) + + BadgeState.markUnread(gameID: move, at: at) + BadgeState.setPendingInvites([invite]) + + // The badge count unions the two disjoint sets. + #expect(BadgeState.unreadGameIDs().union(BadgeState.pendingInviteGameIDs()) + == Set([move, invite])) + + // Clearing invites leaves the moves ledger untouched, and vice versa. + BadgeState.setPendingInvites([]) + #expect(BadgeState.unreadGameIDs() == Set([move])) + } + + @Test("Reset clears pending invites alongside the ledger") + func resetClearsPendingInvites() { + BadgeState.markUnread(gameID: UUID()) + BadgeState.setPendingInvites([UUID()]) + + BadgeState.reset() + #expect(BadgeState.unreadGameIDs().isEmpty) + #expect(BadgeState.pendingInviteGameIDs().isEmpty) + } }