InviteDeliveryStore.swift (3041B)
1 import Foundation 2 import Observation 3 4 enum InviteDeliveryPhase: Equatable { 5 case idle 6 case queued 7 case sent 8 case failed 9 } 10 11 /// Per-recipient delivery state observed by the invite UI. Each row gets a 12 /// stable, narrowly-scoped observable object, so one friend's acknowledgement 13 /// does not invalidate every other friend row. 14 @MainActor 15 @Observable 16 final class InviteDelivery { 17 let gameID: UUID 18 let friendAuthorID: String 19 private(set) var phase: InviteDeliveryPhase = .idle 20 private(set) var failure: InviteDeliveryFailure? 21 22 init(gameID: UUID, friendAuthorID: String) { 23 self.gameID = gameID 24 self.friendAuthorID = friendAuthorID 25 } 26 27 fileprivate func setPhase( 28 _ phase: InviteDeliveryPhase, 29 failure: InviteDeliveryFailure? = nil 30 ) { 31 self.phase = phase 32 self.failure = failure 33 } 34 } 35 36 /// Bridges SyncEngine's record-level acknowledgements to stable invite-row 37 /// models. The record-name index closes the race where CloudKit confirms a 38 /// fast send before the inviting task itself has returned to the view. 39 @MainActor 40 final class InviteDeliveryStore { 41 private struct Key: Hashable { 42 let gameID: UUID 43 let friendAuthorID: String 44 } 45 46 private var deliveries: [Key: InviteDelivery] = [:] 47 private var keyByRecordName: [String: Key] = [:] 48 49 static func failureAnnouncementID(gameID: UUID, friendAuthorID: String) -> String { 50 "invite-delivery-failed-\(gameID.uuidString)-\(friendAuthorID)" 51 } 52 53 func delivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery { 54 let key = Key(gameID: gameID, friendAuthorID: friendAuthorID) 55 if let existing = deliveries[key] { return existing } 56 let delivery = InviteDelivery(gameID: gameID, friendAuthorID: friendAuthorID) 57 deliveries[key] = delivery 58 return delivery 59 } 60 61 func markQueued(recordName: String, gameID: UUID, friendAuthorID: String) { 62 let key = Key(gameID: gameID, friendAuthorID: friendAuthorID) 63 keyByRecordName[recordName] = key 64 let delivery = delivery(gameID: gameID, friendAuthorID: friendAuthorID) 65 guard delivery.phase != .sent else { return } 66 delivery.setPhase(.queued) 67 } 68 69 func markSent(recordName: String, gameID: UUID, friendAuthorID: String) { 70 let key = keyByRecordName[recordName] 71 ?? Key(gameID: gameID, friendAuthorID: friendAuthorID) 72 keyByRecordName.removeValue(forKey: recordName) 73 delivery(gameID: key.gameID, friendAuthorID: key.friendAuthorID) 74 .setPhase(.sent) 75 } 76 77 func markFailed( 78 recordName: String?, 79 gameID: UUID, 80 friendAuthorID: String, 81 failure: InviteDeliveryFailure = .other 82 ) { 83 if let recordName { keyByRecordName.removeValue(forKey: recordName) } 84 let delivery = delivery(gameID: gameID, friendAuthorID: friendAuthorID) 85 guard delivery.phase != .sent else { return } 86 delivery.setPhase(.failed, failure: failure) 87 } 88 }