crossmate

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

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 }