FriendPickerView.swift (7336B)
1 import CoreData 2 import SwiftUI 3 4 /// Lists existing (non-blocked) friends so the user can re-invite one to a 5 /// game without generating and sending a link. Friends are accumulated 6 /// automatically the first time you collaborate with someone (see 7 /// `FriendController`). 8 struct FriendPickerView: View { 9 let gameID: UUID 10 let shareController: ShareController 11 12 @Environment(\.appActions) private var appActions 13 @Environment(\.dismiss) private var dismiss 14 15 @FetchRequest( 16 sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], 17 predicate: NSPredicate(format: "isBlocked == NO"), 18 animation: .default 19 ) 20 private var friends: FetchedResults<FriendEntity> 21 22 @State private var invitingAuthorID: String? 23 @State private var invitedAuthorIDs: Set<String> 24 @State private var isInviteLimitReached: Bool 25 @State private var failedInviteAuthorID: String? 26 @State private var inviteFailure: InviteDeliveryFailure? 27 28 init(gameID: UUID, shareController: ShareController, isInviteLimitReached: Bool = false) { 29 self.gameID = gameID 30 self.shareController = shareController 31 // Seed from the in-memory session set so friends invited a moment ago 32 // already wear their checkmark on the first frame; the .task below 33 // backfills anyone invited in a prior session or on another device. 34 _invitedAuthorIDs = State( 35 initialValue: shareController.invitedAuthorIDsKnownThisSession(for: gameID) 36 ) 37 _isInviteLimitReached = State(initialValue: isInviteLimitReached) 38 } 39 40 private var alphabetizedFriends: [FriendEntity] { 41 friends 42 .map { (friend: $0, name: $0.resolvedDisplayName) } 43 .sorted { lhs, rhs in 44 let comparison = lhs.name.localizedStandardCompare(rhs.name) 45 if comparison != .orderedSame { 46 return comparison == .orderedAscending 47 } 48 return (lhs.friend.authorID ?? "") < (rhs.friend.authorID ?? "") 49 } 50 .map(\.friend) 51 } 52 53 var body: some View { 54 List { 55 if let failedInviteAuthorID, let inviteFailure { 56 Section { 57 InvitationFailureCallout( 58 failure: inviteFailure, 59 isRetrying: invitingAuthorID != nil, 60 retry: { 61 Task { await invite(failedInviteAuthorID) } 62 } 63 ) 64 } 65 } 66 67 Section { 68 if friends.isEmpty { 69 Text("No Prior Crossmates") 70 .font(.body.weight(.medium)) 71 .foregroundStyle(.secondary) 72 .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) 73 } else { 74 ForEach(alphabetizedFriends, id: \.authorID) { friend in 75 friendRow(for: friend) 76 } 77 } 78 } header: { 79 Text("Tap a player to add and notify. The player chooses whether to accept from their Invited list.") 80 .font(.footnote) 81 .foregroundStyle(Color(.secondaryLabel)) 82 .textCase(nil) 83 .padding(.bottom, 8) 84 } 85 } 86 .navigationTitle("Invite a Crossmate") 87 .navigationBarTitleDisplayMode(.inline) 88 .task { 89 // Reflect friends already on the share so re-opening the picker 90 // shows their checkmark instead of an un-invited glyph. 91 let invited = await shareController.invitedAuthorIDs(for: gameID) 92 if !invited.isEmpty { 93 withAnimation(.snappy) { invitedAuthorIDs.formUnion(invited) } 94 } 95 } 96 } 97 98 @ViewBuilder 99 private func friendRow(for friend: FriendEntity) -> some View { 100 let authorID = friend.authorID ?? "" 101 let invited = invitedAuthorIDs.contains(authorID) 102 let deliveryFailed = appActions?.inviteDelivery( 103 gameID: gameID, 104 friendAuthorID: authorID 105 ).phase == .failed 106 Button { 107 Task { await invite(authorID) } 108 } label: { 109 HStack { 110 FriendAvatarView( 111 authorID: authorID, 112 invitePhase: invitePhase(authorID: authorID, invited: invited) 113 ) 114 .padding(.trailing, 8) 115 Text(friend.resolvedDisplayName) 116 Spacer() 117 } 118 } 119 .disabled( 120 authorID.isEmpty 121 || invitingAuthorID != nil 122 || (invited && !deliveryFailed) 123 || (isInviteLimitReached && !invited && !deliveryFailed) 124 ) 125 } 126 127 /// Maps durable delivery ahead of the task-local sending state so the UI 128 /// can show a queued clock as soon as the outbox commits, then reserve the 129 /// checkmark for CloudKit's acknowledgement. 130 private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? { 131 if let delivery = appActions?.inviteDelivery( 132 gameID: gameID, 133 friendAuthorID: authorID 134 ) { 135 switch delivery.phase { 136 case .queued: return .queued 137 case .sent: return .sent 138 case .failed: return nil 139 case .idle: break 140 } 141 } 142 if invitingAuthorID == authorID { return .sending } 143 if invited { return .sent } 144 return nil 145 } 146 147 private func invite(_ authorID: String) async { 148 guard !authorID.isEmpty, let appActions else { return } 149 withAnimation(.snappy) { invitingAuthorID = authorID } 150 failedInviteAuthorID = nil 151 inviteFailure = nil 152 defer { withAnimation(.snappy) { invitingAuthorID = nil } } 153 do { 154 try await appActions.inviteFriend(gameID: gameID, friendAuthorID: authorID) 155 withAnimation(.snappy) { 156 _ = invitedAuthorIDs.insert(authorID) 157 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 158 } 159 } catch SyncEngine.PingOutboxError.deliveryPending { 160 // CloudKit hasn't confirmed within the patience window, but the 161 // invite stays durably queued. Reflect it as invited rather than 162 // failed; the delivery update flips it to failed later if the send 163 // is ultimately rejected. 164 withAnimation(.snappy) { 165 _ = invitedAuthorIDs.insert(authorID) 166 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 167 } 168 } catch { 169 appActions.markInviteFailed( 170 gameID: gameID, 171 friendAuthorID: authorID, 172 error: error 173 ) 174 if case ShareController.ShareError.collaborationLimitReached = error { 175 withAnimation(.snappy) { isInviteLimitReached = true } 176 } 177 withAnimation(.snappy) { 178 failedInviteAuthorID = authorID 179 inviteFailure = InviteDeliveryFailure(error: error) 180 } 181 } 182 } 183 }