crossmate

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

GameShareItem.swift (22525B)


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