crossmate

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

commit 16080a6c31032063b0b7560773b9ac69cb2d42c4
parent fe6c93ba1a4a83722738ee1cc0c27241dde0d652
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 06:17:33 +0900

Free the seat and notify on invitation decline

Declining a puzzle invitation previously only cleaned up on the
decliner's own device — it tombstoned the local InviteEntity and deleted
the source invite Ping. The inviter learned nothing: their CKShare kept
the declined player as a pending participant, so the seat stayed
occupied and could never be re-offered, and no banner told them the
invitation had been turned down.

Now the decline rounds back to the inviter. declineInvite sends a new
.decline Ping into the pairwise friend zone addressed to the inviter,
carrying no payload — the game ID on the record name and the decliner's
authorID together identify the seat to free. On the inviter's device
applyDeclinePing has ShareController.removeFriendParticipant drop that
participant from the game's CKShare, freeing the seat so the owner can
invite someone else, and the notification loop surfaces a 'Bob declined
your invitation' banner. Only a share's owner can manage its
participants, which is why the work lands on the inviter's device rather
than the decliner's.

The decline Ping is consumed from the friend zone only once the seat is
freed, so a transient failure retries on the next sync rather than
stranding the seat; because it lives in the friend zone and not the game
zone, applyDeclinePing deletes it directly instead of through the
notification loop's game-zone consume path. enqueueFriendInvitePing is
generalised to enqueueFriendZonePing so the invite and the decline share
one friend-zone send, and deleteInvitePing becomes deleteFriendZonePing
now that it clears both kinds.

The new decline is just another PingKind string value, so no CloudKit
record changes shape.

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

Diffstat:
MCrossmate/Services/InviteCoordinator.swift | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MCrossmate/Sync/FriendController.swift | 51++++++++++++++++++++++++++++++++++++++++++++++-----
MCrossmate/Sync/Presence.swift | 4++++
MCrossmate/Sync/ShareController.swift | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 25++++++++++++++-----------
MTests/Unit/PuzzleNotificationTextTests.swift | 17+++++++++++++++++
6 files changed, 219 insertions(+), 26 deletions(-)

diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -363,7 +363,7 @@ final class InviteCoordinator { req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName) for invite in try ctx.fetch(req) { if let inviterAuthorID = invite.inviterAuthorID { - await friendController.deleteInvitePing( + await friendController.deleteFriendZonePing( fromFriendAuthorID: inviterAuthorID, recordName: pingRecordName ) @@ -378,8 +378,9 @@ final class InviteCoordinator { /// Declines a pending game invite: marks the durable `InviteEntity` rows for /// `gameID` as a `"declined"` tombstone (which prevents the invite from - /// resurrecting locally if CloudKit deletion is delayed), consumes the - /// source invite Ping so sibling devices clear their rows, and refreshes + /// resurrecting locally if CloudKit deletion is delayed), sends a `.decline` + /// Ping back to each inviter so they free our seat and see a banner, 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`. @@ -391,11 +392,11 @@ final class InviteCoordinator { ) let invites = try ctx.fetch(req) guard !invites.isEmpty else { return } - let pingsToDelete = invites.compactMap { invite -> (String, String)? in + let declined = invites.compactMap { invite -> (inviterAuthorID: String, pingRecordName: String, gameTitle: String)? in guard let inviterAuthorID = invite.inviterAuthorID, let pingRecordName = invite.pingRecordName else { return nil } - return (inviterAuthorID, pingRecordName) + return (inviterAuthorID, pingRecordName, invite.gameTitle ?? "") } for invite in invites { invite.status = "declined" @@ -404,8 +405,26 @@ final class InviteCoordinator { try ctx.save() await refreshAppBadge() } - for (inviterAuthorID, pingRecordName) in pingsToDelete { - await friendController.deleteInvitePing( + let declinerAuthorID = identity.currentID + let declinerName = preferences.name + for (inviterAuthorID, pingRecordName, gameTitle) in declined { + // Tell the inviter so they free our seat and get a banner. Best + // effort — a failed send must not strand the local tombstone or + // block the source-Ping cleanup; the inviter can always re-invite. + if let declinerAuthorID, !declinerAuthorID.isEmpty { + do { + try await friendController.sendDecline( + toInviterAuthorID: inviterAuthorID, + gameID: gameID, + gameTitle: gameTitle, + declinerAuthorID: declinerAuthorID, + declinerName: declinerName + ) + } catch { + syncMonitor.note("decline invite: send decline failed for \(gameID.uuidString) — \(error.localizedDescription)") + } + } + await friendController.deleteFriendZonePing( fromFriendAuthorID: inviterAuthorID, recordName: pingRecordName ) @@ -498,7 +517,7 @@ final class InviteCoordinator { guard !staleNames.isEmpty else { return pings } for ping in candidates where staleNames.contains(ping.recordName) { - await friendController.deleteInvitePing( + await friendController.deleteFriendZonePing( fromFriendAuthorID: ping.authorID, recordName: ping.recordName ) @@ -576,6 +595,12 @@ final class InviteCoordinator { localDisplayName: preferences.name ) } + // Free the seat for any invitee who declined. Done before the + // notification-authorization gate so the share frees even when the + // banner can't be shown; the banner itself is queued by the loop below. + for ping in playerFacingPings where ping.kind == .decline { + await applyDeclinePing(ping) + } guard !playerFacingPings.isEmpty else { return } guard await canPresentNotifications() else { syncMonitor.note("ping: local notification skipped — authorization not granted") @@ -601,8 +626,12 @@ final class InviteCoordinator { // A directed ping addressed to us is consumed by this account: // once handled — shown, suppressed, or a duplicate — delete it so // it stops re-notifying and the deletion withdraws any copy our - // sibling devices showed. Broadcast pings are left as-is. - let consume = ping.addressee != nil && ping.kind != .invite + // sibling devices showed. Broadcast pings are left as-is. `.decline` + // is excluded: it lives in the friend zone, not the game zone this + // path deletes from, so `applyDeclinePing` consumes it instead. + let consume = ping.addressee != nil + && ping.kind != .invite + && ping.kind != .decline func consumeIfDirected() async { guard consume else { return } await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID) @@ -659,6 +688,35 @@ final class InviteCoordinator { } } + /// Frees the seat held by an invitee who declined: asks `ShareController` + /// to remove them from the game's `CKShare` so the owner can invite someone + /// else, then consumes the `.decline` Ping from the friend zone so it stops + /// re-firing. The decliner is the ping's author; only the addressed owner + /// acts. The ping is consumed only on a successful free — a transient + /// failure leaves it so the next sync retries rather than stranding the + /// seat. The banner is queued separately by `presentPings`. + private func applyDeclinePing(_ ping: Ping) async { + guard ping.addressee == identity.currentID, + ping.authorID != identity.currentID, + !ping.authorID.isEmpty + else { return } + do { + try await shareController.removeFriendParticipant( + fromGameID: ping.gameID, + userRecordName: ping.authorID + ) + // Consume from the friend zone (not the game zone) — that's where a + // decline lives, so the loop's game-zone consume path can't reach it. + await friendController.deleteFriendZonePing( + fromFriendAuthorID: ping.authorID, + recordName: ping.recordName + ) + syncMonitor.note("ping(decline): freed seat for \(ping.authorID) in \(ping.gameID.uuidString)") + } catch { + syncMonitor.note("ping(decline): free seat failed for \(ping.gameID.uuidString) — \(error.localizedDescription)") + } + } + private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] { var unclaimed: [Ping] = [] for ping in pings { @@ -697,6 +755,10 @@ final class InviteCoordinator { let player = nickname ?? (ping.playerName.isEmpty ? "A player" : ping.playerName) return "\(player) invited you to \(puzzleSuffix)" + case .decline: + let player = nickname + ?? (ping.playerName.isEmpty ? "A player" : ping.playerName) + return "\(player) declined your invitation to \(puzzleSuffix)" case .friend, .join, .hail: // System-only kinds handled by the friendship-bootstrap / // engagement paths; never presented as a notification. If this diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -280,7 +280,8 @@ final class FriendController { throw FriendError.payloadEncodingFailed } - await syncEngine.enqueueFriendInvitePing( + await syncEngine.enqueueFriendZonePing( + kind: .invite, gameID: gameID, gameTitle: gameTitle, authorID: inviterAuthorID, @@ -292,10 +293,50 @@ final class FriendController { ) } - /// Consume-deletes an `.invite` Ping from the pairwise friend zone after - /// the invite has been accepted or found stale. This removes the source - /// record so the invite cannot be re-created on the recipient's devices. - func deleteInvitePing(fromFriendAuthorID friendAuthorID: String, recordName: String) async { + /// Writes a `.decline` Ping into the friend zone telling the inviter we + /// turned down their game invite, so their device frees our seat on the + /// game's `CKShare` and surfaces a banner. Mirrors `sendInvite` reversed: + /// `declinerAuthorID` is us (the sender), `inviterAuthorID` the addressee. + /// Carries no payload — `(gameID, declinerAuthorID)` fully identify the seat + /// to free. No-ops for an unknown or blocked friend. + func sendDecline( + toInviterAuthorID inviterAuthorID: String, + gameID: UUID, + gameTitle: String, + declinerAuthorID: String, + declinerName: String + ) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "authorID == %@", inviterAuthorID) + req.fetchLimit = 1 + guard let friend = try ctx.fetch(req).first else { + throw FriendError.friendNotFound + } + guard !friend.isBlocked else { throw FriendError.friendBlocked } + guard let zoneName = friend.friendZoneName, + let ownerName = friend.friendZoneOwnerName + else { throw FriendError.friendNotFound } + + await syncEngine.enqueueFriendZonePing( + kind: .decline, + gameID: gameID, + gameTitle: gameTitle, + authorID: declinerAuthorID, + playerName: declinerName, + addressee: inviterAuthorID, + friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + friendZoneScope: friend.databaseScope + ) + } + + /// Consume-deletes a directed Ping from the pairwise friend zone — an + /// `.invite` once it has been accepted or found stale, or a `.decline` once + /// the addressed inviter has freed the seat. Removing the source record + /// stops it re-creating on the recipient's devices and withdraws any banner + /// a sibling showed. `friendAuthorID` is the *other* party on the zone (the + /// inviter for an invite we consume, the decliner for a decline we consume). + func deleteFriendZonePing(fromFriendAuthorID friendAuthorID: String, recordName: String) async { let ctx = persistence.viewContext let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -50,6 +50,10 @@ enum PingKind: String, Sendable { /// Re-invite to a game. Written into a *friend* zone; carries the game's /// share URL in `payload`. Surfaces in the "Invited" section. case invite + /// Invitee-declined notice. Written into a *friend* zone addressed back to + /// the inviter; carries no payload. The inviter's device frees the + /// declined seat on the game's `CKShare` and surfaces a banner. + case decline /// Legacy engagement room bootstrap. Live rooms now rendezvous through /// Game-record engagement credentials; this remains parseable for cleanup. case hail diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -181,6 +181,57 @@ final class ShareController { } } + /// Removes `userRecordName` from the game's `CKShare`, freeing the seat they + /// held so the owner can invite someone else. Called on the owner's device + /// when an invitee declines (an inbound `.decline` Ping): only the owner can + /// manage participants, so the decline rounds back here to do it. No-ops for + /// a game we don't own, an unshared game, or a participant who isn't on the + /// share. Idempotent. + func removeFriendParticipant( + fromGameID gameID: UUID, + userRecordName: String + ) async throws { + syncMonitor?.recordStart("free declined seat") + do { + let ctx = persistence.viewContext + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let entity = try ctx.fetch(request).first, entity.databaseScope == 0 else { + // Not the owner (or the game is gone) — nothing to manage. + syncMonitor?.recordSuccess("free declined seat") + return + } + // Drop the session re-assert first so a concurrent invite save can't + // resurrect the declined participant via the intended-set restore. + sessionInvitedAuthorIDs[gameID]?.remove(userRecordName) + + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + guard let share = try await fetchZoneWideShareIfPresent(zoneName: zoneName), + removeParticipant(userRecordName, from: share) + else { + // No share, or they aren't on it (already removed / never added). + syncMonitor?.recordSuccess("free declined seat") + return + } + do { + _ = try await saveShareForLink(share, for: gameID) + } catch let error as CKError where error.code == .serverRecordChanged { + guard let serverShare = (error as NSError) + .userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare else { + throw error + } + if removeParticipant(userRecordName, from: serverShare) { + _ = try await saveShareForLink(serverShare, for: gameID) + } + } + syncMonitor?.recordSuccess("free declined seat") + } catch { + syncMonitor?.recordError("free declined seat", error) + throw error + } + } + private func addParticipantIfNeeded( _ userRecordName: String, to share: CKShare @@ -194,6 +245,21 @@ final class ShareController { share.addParticipant(participant) } + /// Removes the non-owner participant matching `userRecordName` from `share`, + /// returning whether one was found. Idempotent: a participant already gone + /// returns `false` so the caller can skip a redundant save. + private func removeParticipant( + _ userRecordName: String, + from share: CKShare + ) -> Bool { + guard let participant = share.participants.first(where: { + $0.role != .owner + && $0.userIdentity.userRecordID?.recordName == userRecordName + }) else { return false } + share.removeParticipant(participant) + return true + } + /// Caps the share at `maximumInviteesPerPuzzle` distinct invitees, counting /// the union of those already on the share and everyone we intend to /// (re-)add this save. Author IDs already present don't double-count, so diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -876,15 +876,18 @@ actor SyncEngine { await trace("debug-preview friend purge: deleted \(deleted) row(s)") } - /// Registers an `.invite` Ping into an existing *friend* zone. Unlike - /// `enqueuePing`, the target zone is the friend zone (not the game zone), - /// so the zone and engine are passed in explicitly: `scope == 1` means we - /// joined the friend zone (it lives in our shared DB → shared engine); - /// `scope == 0` means we own it (private DB → private engine). The zone - /// already exists by the time an invite is possible, so no `saveZone`. - /// `gameID` is the *invited* game; it rides the record name so the - /// recipient resolves it without reading the game zone. - func enqueueFriendInvitePing( + /// Registers a directed Ping (`.invite` or `.decline`) into an existing + /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone + /// (not the game zone), so the zone and engine are passed in explicitly: + /// `scope == 1` means we joined the friend zone (it lives in our shared DB + /// → shared engine); `scope == 0` means we own it (private DB → private + /// engine). The zone already exists by the time an invite or decline is + /// possible, so no `saveZone`. `gameID` is the game in question; it rides + /// the record name so the recipient resolves it without reading the game + /// zone. `authorID` is the sender, so an invite carries the inviter and a + /// decline carries the decliner. + func enqueueFriendZonePing( + kind: PingKind, gameID: UUID, gameTitle: String, authorID: String, @@ -892,7 +895,7 @@ actor SyncEngine { addressee: String, friendZoneID: CKRecordZone.ID, friendZoneScope: Int16, - payload: String + payload: String? = nil ) { let engine = friendZoneScope == 1 ? sharedEngine : privateEngine guard let engine else { return } @@ -911,7 +914,7 @@ actor SyncEngine { playerName: playerName, puzzleTitle: gameTitle, eventTimestampMs: eventTimestampMs, - kind: .invite, + kind: kind, payload: payload, addressee: addressee ) diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -221,6 +221,23 @@ struct PuzzleNotificationTextTests { #expect(InviteCoordinator.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'") } + + @Test("Decline body names the decliner and puzzle") + func declineBody() { + let ping = Ping( + recordName: "ping-test-3", + gameID: UUID(), + authorID: "bob", + deviceID: "device-b", + playerName: "Bob", + puzzleTitle: "Saturday Puzzle – 1 January 2001", + kind: .decline, + payload: nil, + addressee: "alice" + ) + + #expect(InviteCoordinator.bodyText(for: ping) == "Bob declined your invitation to the puzzle 'Saturday Puzzle – 1 January 2001'") + } } @Suite("Notification preference muting")