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 }