crossmate

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

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 }