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