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 }