crossmate

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

GameShareItem.swift (22509B)


      1 import Foundation
      2 import MessageUI
      3 import SwiftUI
      4 
      5 struct GameShareSheet: View {
      6     let gameID: UUID
      7     let title: String
      8     let shareController: ShareController
      9 
     10     @Environment(\.appActions) private var appActions
     11     @Environment(\.dismiss) private var dismiss
     12     @Environment(\.syncEngine) private var syncEngine
     13     @Environment(SyncMonitor.self) private var syncMonitor
     14     @Environment(EventLog.self) private var eventLog
     15     @FetchRequest(
     16         sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: false)],
     17         predicate: NSPredicate(format: "isBlocked == NO"),
     18         animation: .default
     19     )
     20     private var friends: FetchedResults<FriendEntity>
     21 
     22     @State private var shareURL: URL?
     23     @State private var shareError: ShareErrorInfo?
     24     @State private var isLoadingExistingLink = true
     25     @State private var isCreating = false
     26     @State private var didLoadExistingLink = false
     27     @State private var didCopy = false
     28     @State private var invitingAuthorID: String?
     29     @State private var invitedAuthorIDs: Set<String>
     30     @State private var isInviteLimitReached = false
     31     @State private var mailReport: MailReport?
     32     @State private var reportFallbackNote: String?
     33     @State private var failedInviteAuthorID: String?
     34     @State private var inviteFailure: InviteDeliveryFailure?
     35 
     36     private static let supportEmail = "[email protected]"
     37     private static let visibleFriendLimit = 12
     38     private static let friendGridColumns = Array(
     39         repeating: GridItem(.flexible(), spacing: 8),
     40         count: 3
     41     )
     42 
     43     init(gameID: UUID, title: String, shareController: ShareController) {
     44         self.gameID = gameID
     45         self.title = title
     46         self.shareController = shareController
     47         // Seed from the in-memory session set so friends invited a moment ago
     48         // already wear their checkmark on the first frame; the .task below
     49         // backfills anyone invited in a prior session or on another device.
     50         _invitedAuthorIDs = State(
     51             initialValue: shareController.invitedAuthorIDsKnownThisSession(for: gameID)
     52         )
     53     }
     54 
     55     private var visibleFriends: Array<FetchedResults<FriendEntity>.Element> {
     56         let recentRanks = appActions?.recentFriendInviteRanks ?? [:]
     57         return Array(
     58             friends.sorted { lhs, rhs in
     59                 let lhsRank = lhs.authorID.flatMap { recentRanks[$0] }
     60                 let rhsRank = rhs.authorID.flatMap { recentRanks[$0] }
     61                 switch (lhsRank, rhsRank) {
     62                 case let (lhsRank?, rhsRank?):
     63                     return lhsRank < rhsRank
     64                 case (_?, nil):
     65                     return true
     66                 case (nil, _?):
     67                     return false
     68                 case (nil, nil):
     69                     return (lhs.createdAt ?? .distantPast) > (rhs.createdAt ?? .distantPast)
     70                 }
     71             }
     72             .prefix(Self.visibleFriendLimit)
     73         )
     74     }
     75 
     76     private var hasMoreFriends: Bool { friends.count > Self.visibleFriendLimit }
     77 
     78     /// A live public link is the definitive signal the owner took the link
     79     /// route, so it wins over any participants the share carries — those are
     80     /// public joiners, not directly invited friends. CloudKit forbids mixing
     81     /// public access with directly added participants on one share (adding a
     82     /// participant to a `.readWrite` share throws), so the two routes are
     83     /// presented as mutually exclusive: choosing one removes the other.
     84     private var isLinkMode: Bool { shareURL != nil }
     85 
     86     /// The direct-invite route is active once a friend has been invited and no
     87     /// public link exists. Gated on the absence of a link so a public joiner
     88     /// accepting (which also lands a participant) never flips the sheet into
     89     /// this mode while the link is still on offer.
     90     private var isDirectInviteMode: Bool { shareURL == nil && !invitedAuthorIDs.isEmpty }
     91 
     92     var body: some View {
     93         NavigationStack {
     94             List {
     95                 VStack(spacing: 18) {
     96                     Image(systemName: "flag.pattern.checkered.2.crossed")
     97                         .font(.system(size: 58, weight: .semibold))
     98                         .symbolRenderingMode(.hierarchical)
     99                         .foregroundStyle(Color.accentColor)
    100                         .frame(width: 88, height: 88)
    101                         .accessibilityHidden(true)
    102 
    103                     VStack(spacing: 12) {
    104                         Text("Share via iCloud")
    105                             .font(.title3.weight(.semibold))
    106                             .multilineTextAlignment(.center)
    107                             .fixedSize(horizontal: false, vertical: true)
    108 
    109                         Text(headerSubtitle)
    110                             .font(.body)
    111                             .multilineTextAlignment(.center)
    112                             .fixedSize(horizontal: false, vertical: true)
    113 
    114                         Text("Crossmate syncs puzzles using iCloud. Your player name is shared with other players. Players can work on the same puzzle simultaneously or at different times.")
    115                             .font(.footnote)
    116                             .foregroundStyle(.secondary)
    117                             .multilineTextAlignment(.center)
    118                             .fixedSize(horizontal: false, vertical: true)
    119                     }
    120                 }
    121                 .frame(maxWidth: .infinity)
    122                 .padding(.horizontal, 8)
    123                 .padding(.vertical, 4)
    124                 .listRowInsets(EdgeInsets())
    125                 .listRowBackground(Color.clear)
    126 
    127                 if let failedInviteAuthorID, let inviteFailure {
    128                     Section {
    129                         InvitationFailureCallout(
    130                             failure: inviteFailure,
    131                             isRetrying: invitingAuthorID != nil,
    132                             retry: {
    133                                 Task { await invite(failedInviteAuthorID) }
    134                             }
    135                         )
    136                     }
    137                 }
    138 
    139                 Section {
    140                     if isDirectInviteMode {
    141                         Label("Link Sharing Unavailable", systemImage: "link.badge.plus")
    142                             .foregroundStyle(.secondary)
    143                         Text("You've invited a crossmate directly. Everyone joins this puzzle the same way, so a share link isn't available.")
    144                             .font(.footnote)
    145                             .foregroundStyle(.secondary)
    146                     } else if !isInviteLimitReached {
    147                         if let shareURL {
    148                             Button {
    149                                 UIPasteboard.general.string = shareURL.absoluteString
    150                                 didCopy = true
    151                             } label: {
    152                                 Label(didCopy ? "Copied" : "Copy Link", systemImage: didCopy ? "checkmark" : "doc.on.doc")
    153                             }
    154 
    155                             ShareLink(item: shareURL) {
    156                                 Label("Send Link", systemImage: "square.and.arrow.up")
    157                             }
    158                         } else if isLoadingExistingLink {
    159                             HStack {
    160                                 Label("Checking Link", systemImage: "link")
    161                                 Spacer()
    162                                 ProgressView()
    163                             }
    164                         } else {
    165                             Button {
    166                                 Task { await createLink() }
    167                             } label: {
    168                                 HStack {
    169                                     Label("Create Link", systemImage: "link")
    170                                     if isCreating {
    171                                         Spacer()
    172                                         ProgressView()
    173                                     }
    174                                 }
    175                             }
    176                             .disabled(isCreating || isLoadingExistingLink)
    177                         }
    178                     } else {
    179                         Label("Link Sharing Disabled", systemImage: "link.badge.plus")
    180                             .foregroundStyle(.secondary)
    181                         Text("This puzzle already has its crossmates.")
    182                             .font(.footnote)
    183                             .foregroundStyle(.secondary)
    184                     }
    185                 }
    186 
    187                 Section {
    188                     if isLinkMode {
    189                         VStack(spacing: 6) {
    190                             Label("Direct Invites Unavailable", systemImage: "person.crop.circle.badge.xmark")
    191                                 .foregroundStyle(.secondary)
    192                             Text("You've created a share link. Anyone with the link can join, so direct invites aren't available for this puzzle.")
    193                                 .font(.footnote)
    194                                 .foregroundStyle(.secondary)
    195                                 .multilineTextAlignment(.center)
    196                         }
    197                         .frame(maxWidth: .infinity, minHeight: 72, alignment: .center)
    198                         .padding(.vertical, 4)
    199                     } else if visibleFriends.isEmpty {
    200                         Text("No Prior Crossmates")
    201                             .font(.body.weight(.medium))
    202                             .foregroundStyle(.secondary)
    203                             .frame(maxWidth: .infinity, minHeight: 72, alignment: .center)
    204                     } else {
    205                         VStack(spacing: 12) {
    206                             LazyVGrid(columns: Self.friendGridColumns, spacing: 8) {
    207                                 ForEach(visibleFriends, id: \.authorID) { friend in
    208                                     friendInviteButton(for: friend)
    209                                 }
    210                             }
    211 
    212                             if hasMoreFriends {
    213                                 NavigationLink {
    214                                     FriendPickerView(
    215                                         gameID: gameID,
    216                                         shareController: shareController,
    217                                         isInviteLimitReached: isInviteLimitReached
    218                                     )
    219                                 } label: {
    220                                     Text("See All Crossmates")
    221                                         .font(.callout.weight(.medium))
    222                                 }
    223                                 .disabled(isLoadingExistingLink)
    224                             }
    225                         }
    226                         .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
    227                     }
    228                 }
    229 
    230                 if let shareError {
    231                     Section {
    232                         Text(shareError.message)
    233                             .font(.footnote)
    234                             .foregroundStyle(.red)
    235                         if shareError.isReportable {
    236                             Button {
    237                                 Task { await reportError(shareError) }
    238                             } label: {
    239                                 Label("Report Error", systemImage: "envelope")
    240                             }
    241                             Button {
    242                                 UIPasteboard.general.string = shareError.detail
    243                             } label: {
    244                                 Label("Copy Error Details", systemImage: "doc.on.doc")
    245                             }
    246                             if let reportFallbackNote {
    247                                 Text(reportFallbackNote)
    248                                     .font(.footnote)
    249                                     .foregroundStyle(.secondary)
    250                             }
    251                         }
    252                     }
    253                 }
    254             }
    255             .navigationTitle("Invite Players")
    256             .navigationBarTitleDisplayMode(.inline)
    257             .toolbar {
    258                 ToolbarItem(placement: .cancellationAction) {
    259                     Button {
    260                         dismiss()
    261                     } label: {
    262                         Image(systemName: "xmark")
    263                     }
    264                     .accessibilityLabel("Cancel")
    265                 }
    266             }
    267             .task {
    268                 // All three loads hit CloudKit; run them together so a full
    269                 // game shows its disabled invite buttons — and already-invited
    270                 // friends show their checkmark — as fast as the link check.
    271                 async let seatTaken = shareController.isAtInviteCapacity(for: gameID)
    272                 async let alreadyInvited = shareController.invitedAuthorIDs(for: gameID)
    273                 await loadExistingLink()
    274                 if await seatTaken {
    275                     isInviteLimitReached = true
    276                 }
    277                 let invited = await alreadyInvited
    278                 if !invited.isEmpty {
    279                     withAnimation(.snappy) { invitedAuthorIDs.formUnion(invited) }
    280                 }
    281             }
    282             .sheet(item: $mailReport) { report in
    283                 MailComposeView(
    284                     recipients: [Self.supportEmail],
    285                     subject: report.subject,
    286                     body: report.body,
    287                     attachment: .text(report.attachmentText, fileName: "crossmate-diagnostics.txt"),
    288                     onFinish: { mailReport = nil }
    289                 )
    290                 .ignoresSafeArea()
    291             }
    292         }
    293     }
    294 
    295     private var headerSubtitle: String {
    296         if isInviteLimitReached {
    297             "This puzzle already has its crossmates. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players."
    298         } else {
    299             "Anyone with the link can join your puzzle. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players."
    300         }
    301     }
    302 
    303     @ViewBuilder
    304     private func friendInviteButton(for friend: FriendEntity) -> some View {
    305         let authorID = friend.authorID ?? ""
    306         let wasInvited = invitedAuthorIDs.contains(authorID)
    307         let deliveryFailed = appActions?.inviteDelivery(
    308             gameID: gameID,
    309             friendAuthorID: authorID
    310         ).phase == .failed
    311 
    312         Button {
    313             Task { await invite(authorID) }
    314         } label: {
    315             VStack(spacing: 8) {
    316                 FriendAvatarView(
    317                     authorID: authorID,
    318                     size: 40,
    319                     invitePhase: invitePhase(authorID: authorID, invited: wasInvited)
    320                 )
    321                 Text(friend.resolvedDisplayName)
    322                     .font(.callout.weight(.medium))
    323                     .lineLimit(1)
    324                     .minimumScaleFactor(0.8)
    325             }
    326             .frame(maxWidth: .infinity, minHeight: 88)
    327         }
    328         .buttonStyle(.plain)
    329         .disabled(
    330             authorID.isEmpty
    331                 || invitingAuthorID != nil
    332                 || (wasInvited && !deliveryFailed)
    333                 || isLoadingExistingLink
    334                 || (isInviteLimitReached && !wasInvited && !deliveryFailed)
    335         )
    336     }
    337 
    338     /// Maps durable delivery ahead of the task-local sending state so the UI
    339     /// can show a queued clock as soon as the outbox commits, then reserve the
    340     /// checkmark for CloudKit's acknowledgement.
    341     private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? {
    342         if let delivery = appActions?.inviteDelivery(
    343             gameID: gameID,
    344             friendAuthorID: authorID
    345         ) {
    346             switch delivery.phase {
    347             case .queued: return .queued
    348             case .sent: return .sent
    349             case .failed: return nil
    350             case .idle: break
    351             }
    352         }
    353         if invitingAuthorID == authorID { return .sending }
    354         if invited { return .sent }
    355         return nil
    356     }
    357 
    358     private func invite(_ authorID: String) async {
    359         guard !authorID.isEmpty, let appActions else { return }
    360         withAnimation(.snappy) { invitingAuthorID = authorID }
    361         shareError = nil
    362         failedInviteAuthorID = nil
    363         inviteFailure = nil
    364         defer { withAnimation(.snappy) { invitingAuthorID = nil } }
    365 
    366         do {
    367             try await appActions.inviteFriend(gameID: gameID, friendAuthorID: authorID)
    368             withAnimation(.snappy) {
    369                 _ = invitedAuthorIDs.insert(authorID)
    370                 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
    371             }
    372         } catch SyncEngine.PingOutboxError.deliveryPending {
    373             // CloudKit hasn't confirmed within the patience window, but the
    374             // invite stays durably queued. Reflect it as invited rather than
    375             // failed; the delivery update flips it to failed later if the send
    376             // is ultimately rejected.
    377             withAnimation(.snappy) {
    378                 _ = invitedAuthorIDs.insert(authorID)
    379                 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
    380             }
    381         } catch {
    382             appActions.markInviteFailed(
    383                 gameID: gameID,
    384                 friendAuthorID: authorID,
    385                 error: error
    386             )
    387             if case ShareController.ShareError.collaborationLimitReached = error {
    388                 withAnimation(.snappy) { isInviteLimitReached = true }
    389             }
    390             if error is ShareController.ShareError {
    391                 shareError = ShareErrorInfo(error, diagnostic: describe(error))
    392             } else {
    393                 withAnimation(.snappy) {
    394                     failedInviteAuthorID = authorID
    395                     inviteFailure = InviteDeliveryFailure(error: error)
    396                 }
    397             }
    398         }
    399     }
    400 
    401     private func loadExistingLink() async {
    402         guard !didLoadExistingLink else { return }
    403         didLoadExistingLink = true
    404         isLoadingExistingLink = true
    405         shareError = nil
    406         defer { isLoadingExistingLink = false }
    407 
    408         do {
    409             let shape = shareController.gridSilhouette(for: gameID)
    410             shareURL = (try await shareController.existingShareLink(for: gameID))
    411                 .map { ShareLinkShortener.shortURL(for: $0, title: title, shape: shape) }
    412         } catch {
    413             shareError = ShareErrorInfo(error, diagnostic: describe(error))
    414         }
    415     }
    416 
    417     private func createLink() async {
    418         guard !isCreating, !isLoadingExistingLink else { return }
    419         isCreating = true
    420         didCopy = false
    421         shareError = nil
    422         defer { isCreating = false }
    423 
    424         do {
    425             shareURL = ShareLinkShortener.shortURL(
    426                 for: try await shareController.createShareLink(for: gameID),
    427                 title: title,
    428                 shape: shareController.gridSilhouette(for: gameID)
    429             )
    430         } catch {
    431             shareError = ShareErrorInfo(error, diagnostic: describe(error))
    432         }
    433     }
    434 
    435     /// Pairs a short, user-facing message with the full diagnostic. The message
    436     /// is shown on screen; the diagnostic is what the Copy/Report buttons carry
    437     /// so a report still contains the underlying CloudKit detail.
    438     private struct ShareErrorInfo {
    439         let message: String
    440         let detail: String
    441         /// True for unexpected failures (CloudKit, network) worth reporting.
    442         /// Our own `ShareError` cases are expected states with clear copy, so
    443         /// they get no Report/Copy affordance.
    444         let isReportable: Bool
    445 
    446         init(_ error: Error, diagnostic: String) {
    447             if error is ShareController.ShareError {
    448                 message = error.localizedDescription
    449                 isReportable = false
    450             } else {
    451                 message = "Something went wrong sharing this puzzle. If it keeps happening, report the error so it can be fixed."
    452                 isReportable = true
    453             }
    454             detail = diagnostic
    455         }
    456     }
    457 
    458     /// Identifiable payload driving the mail-compose sheet.
    459     private struct MailReport: Identifiable {
    460         let id = UUID()
    461         let subject: String
    462         let body: String
    463         let attachmentText: String
    464     }
    465 
    466     /// Builds a diagnostics report for the failure and hands it to Mail,
    467     /// pre-addressed to support with the log attached. Falls back to copying the
    468     /// full report when the device has no Mail account configured.
    469     private func reportError(_ info: ShareErrorInfo) async {
    470         reportFallbackNote = nil
    471         // Freshen the snapshot so the report's header reflects current sync
    472         // state, not whatever the Diagnostics screen last loaded (it may never
    473         // have been opened this session).
    474         if let syncEngine {
    475             let snapshot = await syncEngine.diagnosticSnapshot()
    476             syncMonitor.updateSnapshot(snapshot)
    477         }
    478         let dump = DiagnosticsReport.dump(
    479             syncMonitor: syncMonitor,
    480             eventLog: eventLog,
    481             leadingLines: [
    482                 "Puzzle: \(title)",
    483                 "Failure while sharing this puzzle:",
    484                 // Already scrubbed at its source in `describe(_:)`.
    485                 info.detail
    486             ]
    487         )
    488         let rendered = dump.rendered()
    489 
    490         guard MFMailComposeViewController.canSendMail() else {
    491             UIPasteboard.general.string = rendered
    492             reportFallbackNote = "Mail isn't set up on this device. The full report was copied — please email it to \(Self.supportEmail)."
    493             return
    494         }
    495 
    496         mailReport = MailReport(
    497             subject: "Crossmate error report",
    498             body: "I hit a problem sharing a puzzle in Crossmate. The diagnostics log is attached.\n\n",
    499             attachmentText: rendered
    500         )
    501     }
    502 
    503     private func describe(_ error: Error) -> String {
    504         let nsError = error as NSError
    505         let userInfo = nsError.userInfo
    506             .map { "\($0.key)=\($0.value)" }
    507             .joined(separator: " | ")
    508         let raw = "domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)\n\(userInfo)"
    509         // Scrub before this reaches the Copy button or the emailed report, so a
    510         // shared error can't leak full game UUIDs, zone/record names, or a
    511         // share-link token — the same policy the event log already enforces.
    512         return LogScrubber.scrub(raw)
    513     }
    514 }