crossmate

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

GameListView.swift (26570B)


      1 import CoreData
      2 import SwiftUI
      3 
      4 struct GameListView: View {
      5     let store: GameStore
      6     let shareController: ShareController
      7     let authorIdentity: AuthorIdentity
      8     let onRefresh: () async -> Void
      9     let onAppear: () async -> Void
     10     let onDisappear: () -> Void
     11     let onAcceptInvite: ((String, String, GridSilhouette.Grid?) async throws -> Void)?
     12     @Binding var navigationPath: NavigationPath
     13 
     14     @Environment(\.managedObjectContext) private var viewContext
     15     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     16     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
     17     @FetchRequest(
     18         sortDescriptors: [],
     19         animation: .default
     20     )
     21     private var games: FetchedResults<GameEntity>
     22 
     23     @FetchRequest(
     24         sortDescriptors: [NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: true)],
     25         predicate: NSPredicate(format: "status == %@", "pending"),
     26         animation: .default
     27     )
     28     private var pendingInvites: FetchedResults<InviteEntity>
     29 
     30     @FetchRequest(
     31         sortDescriptors: [],
     32         predicate: NSPredicate(format: "isBlocked == YES")
     33     )
     34     private var blockedFriends: FetchedResults<FriendEntity>
     35 
     36     @Environment(\.acceptInvite) private var acceptInvite
     37     @Environment(\.declineInvite) private var declineInvite
     38     @Environment(\.blockFriend) private var blockFriend
     39     @Environment(\.sendResignPings) private var sendResignPings
     40     @Environment(PlayerPreferences.self) private var preferences
     41     @Environment(AnnouncementCenter.self) private var announcements
     42     @Environment(TipStore.self) private var tips
     43     @State private var acceptingInviteID: NSManagedObjectID?
     44     @State private var blockTarget: InviteEntity?
     45 
     46     @State private var showingNewGame = false
     47     @State private var showingSettings = false
     48     @State private var showingFriends = false
     49     @State private var deleteTarget: GameSummary?
     50     @State private var resignTarget: GameSummary?
     51     @State private var leaveTarget: GameSummary?
     52     @State private var leaveError: Error?
     53     @State private var showingNamePrompt = false
     54     @State private var nameDraft = ""
     55     @State private var summaryCache = GameSummaryCache()
     56     @State private var completedVisibleCount = completedPageSize
     57     /// Shows the "Never show me tips" opt-out in the banner slot for a few
     58     /// seconds after a tip is dismissed; cleared by the timer or by tapping it.
     59     @State private var showTipOptOut = false
     60     @State private var tipOptOutHideTask: Task<Void, Never>?
     61 
     62     private static let completedPageSize = 7
     63 
     64     var body: some View {
     65         GeometryReader { geometry in
     66             VStack(spacing: 0) {
     67                 if let announcement = announcements.currentGlobal() {
     68                     AnnouncementBanner(announcement: announcement) {
     69                         dismissAnnouncement(announcement)
     70                     }
     71                     .padding(.horizontal)
     72                     .padding(.top, 8)
     73                     .transition(.move(edge: .top).combined(with: .opacity))
     74                 } else if showTipOptOut {
     75                     Button("Never Show Tips") {
     76                         tipOptOutHideTask?.cancel()
     77                         tips.disable()
     78                         showTipOptOut = false
     79                     }
     80                     .buttonStyle(.borderedProminent)
     81                     .buttonBorderShape(.capsule)
     82                     .controlSize(.small)
     83                     .padding(.top, 8)
     84                     .transition(.opacity)
     85                 }
     86                 content(usesRoomierType: usesRoomierType(for: geometry.size))
     87             }
     88             .background(Color(.systemGroupedBackground))
     89             .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal())
     90             .animation(.easeInOut(duration: 0.3), value: showTipOptOut)
     91         }
     92         .navigationTitle("")
     93         .navigationBarTitleDisplayMode(.inline)
     94         .toolbar {
     95             ToolbarItem(placement: .topBarLeading) {
     96                 Button {
     97                     showingSettings = true
     98                 } label: {
     99                     Image(systemName: "gearshape")
    100                 }
    101             }
    102             ToolbarItem(placement: .topBarTrailing) {
    103                 Button {
    104                     showingFriends = true
    105                 } label: {
    106                     Image(systemName: "person.2")
    107                 }
    108             }
    109             if #available(iOS 26.0, *) {
    110                 ToolbarSpacer(.fixed, placement: .topBarTrailing)
    111             }
    112             ToolbarItem(placement: .topBarTrailing) {
    113                 Button {
    114                     showingNewGame = true
    115                 } label: {
    116                     Image(systemName: "plus")
    117                 }
    118             }
    119         }
    120         .sheet(isPresented: $showingSettings) {
    121             SettingsView()
    122         }
    123         .sheet(isPresented: $showingFriends) {
    124             FriendsView()
    125         }
    126         .sheet(isPresented: $showingNewGame) {
    127             NewGameSheet(store: store) { gameID in
    128                 navigationPath.append(gameID)
    129             }
    130         }
    131         .task {
    132             await onAppear()
    133         }
    134         .onDisappear {
    135             onDisappear()
    136         }
    137         .alert("Resign Puzzle?", isPresented: .init(
    138             get: { resignTarget != nil },
    139             set: { if !$0 { resignTarget = nil } }
    140         )) {
    141             Button("Resign", role: .destructive) {
    142                 if let target = resignTarget {
    143                     do {
    144                         try store.resignGame(id: target.id)
    145                         let id = target.id
    146                         Task { await sendResignPings?(id) }
    147                     } catch {
    148                         announcements.post(Announcement(
    149                             id: Self.destructiveActionErrorID,
    150                             scope: .global,
    151                             severity: .error,
    152                             title: "Resigning Failed",
    153                             body: error.localizedDescription,
    154                             dismissal: .manual
    155                         ))
    156                     }
    157                 }
    158             }
    159             Button("Cancel", role: .cancel) {}
    160         } message: {
    161             if let target = resignTarget {
    162                 Text("This will reveal all answers for \"\(target.title)\".")
    163             }
    164         }
    165         .alert("Leave Puzzle?", isPresented: .init(
    166             get: { leaveTarget != nil },
    167             set: { if !$0 { leaveTarget = nil } }
    168         )) {
    169             Button("Leave", role: .destructive) {
    170                 if let target = leaveTarget {
    171                     Task { await leaveShare(game: target) }
    172                 }
    173             }
    174             Button("Cancel", role: .cancel) {}
    175         } message: {
    176             if let target = leaveTarget {
    177                 Text("You will lose access to \"\(target.title)\".")
    178             }
    179         }
    180         .alert("Delete Puzzle?", isPresented: .init(
    181             get: { deleteTarget != nil },
    182             set: { if !$0 { deleteTarget = nil } }
    183         )) {
    184             Button("Delete", role: .destructive) {
    185                 if let target = deleteTarget {
    186                     do {
    187                         try store.deleteGame(id: target.id)
    188                     } catch {
    189                         announcements.post(Announcement(
    190                             id: Self.destructiveActionErrorID,
    191                             scope: .global,
    192                             severity: .error,
    193                             title: "Deleting Failed",
    194                             body: error.localizedDescription,
    195                             dismissal: .manual
    196                         ))
    197                     }
    198                 }
    199             }
    200             Button("Cancel", role: .cancel) {}
    201         } message: {
    202             if let target = deleteTarget {
    203                 if target.isOwned && target.isShared {
    204                     Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.")
    205                 } else {
    206                     Text("This will permanently delete \"\(target.title)\" and all progress.")
    207                 }
    208             }
    209         }
    210         .alert("Block This Player?", isPresented: .init(
    211             get: { blockTarget != nil },
    212             set: { if !$0 { blockTarget = nil } }
    213         )) {
    214             Button("Block", role: .destructive) {
    215                 if let target = blockTarget, let authorID = target.inviterAuthorID {
    216                     Task { await blockFriend?(authorID) }
    217                 }
    218             }
    219             Button("Cancel", role: .cancel) {}
    220         } message: {
    221             let name = blockTarget?.resolvedInviterName ?? "this player"
    222             Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be removed from this device.")
    223         }
    224         .alert("Set Profile Name", isPresented: $showingNamePrompt) {
    225             TextField("Name", text: $nameDraft)
    226                 .textInputAutocapitalization(.never)
    227                 .autocorrectionDisabled()
    228             Button("Cancel", role: .cancel) {}
    229             Button("Save") {
    230                 let trimmed = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
    231                 if !trimmed.isEmpty {
    232                     preferences.name = trimmed
    233                     nameDraft = trimmed
    234                 }
    235             }
    236             .keyboardShortcut(.defaultAction)
    237         } message: {
    238             Text("Enter the name other players will see.")
    239         }
    240     }
    241 
    242     /// Dismisses the banner's announcement. For a tip, also records it as
    243     /// dismissed so it never returns, and surfaces the "Never show me tips"
    244     /// opt-out in its place for a few seconds.
    245     private func dismissAnnouncement(_ announcement: Announcement) {
    246         announcements.dismiss(id: announcement.id)
    247         guard let tipID = Tip.tipID(fromAnnouncementID: announcement.id) else { return }
    248         tips.markDismissed(tipID)
    249         showTipOptOut = true
    250         tipOptOutHideTask?.cancel()
    251         tipOptOutHideTask = Task { @MainActor in
    252             try? await Task.sleep(for: .seconds(6))
    253             guard !Task.isCancelled else { return }
    254             showTipOptOut = false
    255         }
    256     }
    257 
    258     @ViewBuilder
    259     private func content(usesRoomierType: Bool) -> some View {
    260         let summaries = games.compactMap {
    261             summaryCache.summary(
    262                 for: $0,
    263                 localAuthorID: authorIdentity.currentID,
    264                 localName: preferences.name,
    265                 localColor: preferences.color
    266             )
    267         }
    268         let inProgress = summaries
    269             .filter { $0.completedAt == nil && !$0.isAccessRevoked }
    270             .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
    271         let revoked = summaries
    272             .filter { $0.isAccessRevoked }
    273             .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
    274         let completed = summaries
    275             .filter { $0.completedAt != nil && !$0.isAccessRevoked }
    276             .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
    277         let visibleCount = min(completedVisibleCount, completed.count)
    278         let visibleCompleted = Array(completed.prefix(visibleCount))
    279         let hasMore = visibleCount < completed.count
    280 
    281         let blockedIDs = Set(blockedFriends.compactMap { $0.authorID })
    282         let visibleInvites = pendingInvites.filter {
    283             guard let inviter = $0.inviterAuthorID else { return true }
    284             return !blockedIDs.contains(inviter)
    285         }
    286 
    287         Group {
    288             if horizontalSizeClass == .regular {
    289                 gridLayout(
    290                     invites: visibleInvites,
    291                     inProgress: inProgress,
    292                     revoked: revoked,
    293                     completed: visibleCompleted,
    294                     hasMore: hasMore,
    295                     usesRoomierType: usesRoomierType
    296                 )
    297             } else {
    298                 listLayout(
    299                     invites: visibleInvites,
    300                     inProgress: inProgress,
    301                     revoked: revoked,
    302                     completed: visibleCompleted,
    303                     hasMore: hasMore,
    304                     usesRoomierType: usesRoomierType
    305                 )
    306             }
    307         }
    308         .overlay {
    309             if games.isEmpty {
    310                 if preferences.hasName {
    311                     ContentUnavailableView {
    312                         Label("No Puzzles", systemImage: "square.grid.3x3")
    313                     } description: {
    314                         Text("Tap the + button to start a new puzzle, or pull down to refresh.")
    315                     }
    316                 } else {
    317                     ContentUnavailableView {
    318                         Label("Set Your Profile Name", systemImage: "person.text.rectangle")
    319                     } description: {
    320                         Text("Choose the name other players will see.")
    321                     } actions: {
    322                         Button {
    323                             nameDraft = ""
    324                             showingNamePrompt = true
    325                         } label: { Text("Set Profile Name") }
    326                         .buttonStyle(.borderedProminent)
    327                     }
    328                 }
    329             }
    330         }
    331         .onChange(of: completed.count) { oldCount, newCount in
    332             if newCount > oldCount {
    333                 completedVisibleCount += (newCount - oldCount)
    334             }
    335         }
    336     }
    337 
    338     // MARK: - List layout (compact width / iPhone)
    339 
    340     @ViewBuilder
    341     private func listLayout(
    342         invites: [InviteEntity],
    343         inProgress: [GameSummary],
    344         revoked: [GameSummary],
    345         completed: [GameSummary],
    346         hasMore: Bool,
    347         usesRoomierType: Bool
    348     ) -> some View {
    349         List {
    350             if !invites.isEmpty {
    351                 Section {
    352                     ForEach(invites, id: \.objectID) { invite in
    353                         inviteRow(for: invite)
    354                     }
    355                 } header: {
    356                     Text("Invited")
    357                 }
    358             }
    359 
    360             if !inProgress.isEmpty {
    361                 Section {
    362                     ForEach(inProgress) { game in
    363                         rowView(for: game, usesRoomierType: usesRoomierType)
    364                     }
    365                 } header: {
    366                     Text("In Progress")
    367                 }
    368             }
    369 
    370             if !revoked.isEmpty {
    371                 Section {
    372                     ForEach(revoked) { game in
    373                         rowView(for: game, usesRoomierType: usesRoomierType)
    374                     }
    375                 } header: {
    376                     Text("Revoked")
    377                 }
    378             }
    379 
    380             if !completed.isEmpty {
    381                 Section {
    382                     ForEach(completed) { game in
    383                         rowView(for: game, usesRoomierType: usesRoomierType)
    384                     }
    385                 } header: {
    386                     Text("Completed")
    387                 } footer: {
    388                     if hasMore {
    389                         loadMoreButton
    390                     }
    391                 }
    392             }
    393         }
    394         .refreshable {
    395             await onRefresh()
    396         }
    397     }
    398 
    399     // MARK: - Grid layout (regular width / iPad)
    400 
    401     private var gridColumns: [GridItem] {
    402         [GridItem(.adaptive(minimum: 320), spacing: 12)]
    403     }
    404 
    405     @ViewBuilder
    406     private func gridLayout(
    407         invites: [InviteEntity],
    408         inProgress: [GameSummary],
    409         revoked: [GameSummary],
    410         completed: [GameSummary],
    411         hasMore: Bool,
    412         usesRoomierType: Bool
    413     ) -> some View {
    414         ScrollView {
    415             LazyVStack(spacing: 8) {
    416                 if !invites.isEmpty {
    417                     Section {
    418                         LazyVGrid(columns: gridColumns, spacing: 12) {
    419                             ForEach(invites, id: \.objectID) { invite in
    420                                 inviteCard(for: invite)
    421                             }
    422                         }
    423                         .padding(.horizontal)
    424                     } header: {
    425                         gridSectionHeader("Invited")
    426                     }
    427                 }
    428 
    429                 if !inProgress.isEmpty {
    430                     Section {
    431                         LazyVGrid(columns: gridColumns, spacing: 12) {
    432                             ForEach(inProgress) { game in
    433                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    434                             }
    435                         }
    436                         .padding(.horizontal)
    437                     } header: {
    438                         gridSectionHeader("In Progress")
    439                     }
    440                 }
    441 
    442                 if !revoked.isEmpty {
    443                     Section {
    444                         LazyVGrid(columns: gridColumns, spacing: 12) {
    445                             ForEach(revoked) { game in
    446                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    447                             }
    448                         }
    449                         .padding(.horizontal)
    450                     } header: {
    451                         gridSectionHeader("Revoked")
    452                     }
    453                 }
    454 
    455                 if !completed.isEmpty {
    456                     Section {
    457                         LazyVGrid(columns: gridColumns, spacing: 12) {
    458                             ForEach(completed) { game in
    459                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    460                             }
    461                         }
    462                         .padding(.horizontal)
    463 
    464                         if hasMore {
    465                             loadMoreButton
    466                                 .padding(.horizontal)
    467                         }
    468                     } header: {
    469                         gridSectionHeader("Completed")
    470                     }
    471                 }
    472             }
    473             .padding(.vertical, 8)
    474         }
    475         .background(Color(.systemGroupedBackground))
    476         .refreshable {
    477             await onRefresh()
    478         }
    479     }
    480 
    481     private func gridSectionHeader(_ title: String) -> some View {
    482         Text(title)
    483             .font(.footnote.weight(.semibold))
    484             .foregroundStyle(.secondary)
    485             .frame(maxWidth: .infinity, alignment: .leading)
    486             .padding(.horizontal, 16)
    487             .padding(.vertical, 8)
    488             .background(Color(.systemGroupedBackground))
    489     }
    490 
    491     private var loadMoreButton: some View {
    492         HStack {
    493             Spacer()
    494             Button {
    495                 withAnimation(.easeInOut(duration: 0.25)) {
    496                     completedVisibleCount += Self.completedPageSize
    497                 }
    498             } label: {
    499                 Text("Load More")
    500                     .font(.subheadline.weight(.semibold))
    501                     .foregroundColor(.secondary)
    502                     .padding(.horizontal, 18)
    503                     .padding(.vertical, 8)
    504                     .background(Color(.tertiarySystemFill), in: Capsule())
    505             }
    506             .buttonStyle(.plain)
    507             .textCase(nil)
    508             Spacer()
    509         }
    510         .padding(.top, 8)
    511     }
    512 
    513     private func gameCard(for game: GameSummary, usesRoomierType: Bool) -> some View {
    514         GameCardView(
    515             game: game,
    516             shareController: shareController,
    517             usesRoomierType: usesRoomierType,
    518             onResume: { navigationPath.append(game.id) },
    519             onLeave: { leaveTarget = game },
    520             onResign: { resignTarget = game },
    521             onDelete: { deleteTarget = game }
    522         )
    523     }
    524 
    525     /// The puzzle-shape preview for an invite, decoded from the silhouette
    526     /// segment the inviter sent. Open cells render grey (`.filled`) to read as
    527     /// "not yet playable", matching the link-tap placeholder in
    528     /// `JoiningPuzzleView`. An absent or undecodable grid gets no thumbnail.
    529     @ViewBuilder
    530     private func inviteThumbnail(for invite: InviteEntity) -> some View {
    531         if let segment = invite.gridSilhouette,
    532            let shape = GridSilhouette.decode(segment) {
    533             GridThumbnailView(
    534                 width: shape.width,
    535                 height: shape.height,
    536                 cells: shape.blocks.map { $0 ? .block : .filled }
    537             )
    538         }
    539     }
    540 
    541     @ViewBuilder
    542     private func inviteCard(for invite: InviteEntity) -> some View {
    543         let inviter = invite.resolvedInviterName ?? "A player"
    544         let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
    545         HStack(spacing: 12) {
    546             inviteThumbnail(for: invite)
    547             VStack(alignment: .leading, spacing: 2) {
    548                 Text(title)
    549                     .font(.subheadline.weight(.semibold))
    550                     .lineLimit(1)
    551                     .truncationMode(.tail)
    552                 Text("Invited by \(inviter)")
    553                     .font(.footnote)
    554                     .foregroundStyle(.secondary)
    555                     .lineLimit(1)
    556             }
    557             Spacer(minLength: 0)
    558             if acceptingInviteID == invite.objectID {
    559                 ProgressView()
    560             } else {
    561                 Button("Accept") { Task { await accept(invite) } }
    562                     .buttonStyle(.borderedProminent)
    563                     .controlSize(.small)
    564             }
    565             inviteMenu(for: invite)
    566         }
    567         .padding(12)
    568         .frame(maxWidth: .infinity)
    569         .frame(height: CardMetrics.height)
    570         .background(
    571             Color(.secondarySystemGroupedBackground),
    572             in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
    573         )
    574     }
    575 
    576     private func inviteMenu(for invite: InviteEntity) -> some View {
    577         Menu {
    578             Button { Task { await decline(invite) } } label: {
    579                 Label("Decline", systemImage: "xmark")
    580             }
    581             Button(role: .destructive) { blockTarget = invite } label: {
    582                 Label("Block", systemImage: "hand.raised")
    583             }
    584         } label: {
    585             Text("More")
    586                 .foregroundStyle(.primary)
    587         }
    588         .buttonStyle(.bordered)
    589         .controlSize(.small)
    590         .tint(.secondary)
    591         .compositingGroup()
    592     }
    593 
    594     @ViewBuilder
    595     private func inviteRow(for invite: InviteEntity) -> some View {
    596         let inviter = invite.resolvedInviterName ?? "A player"
    597         let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
    598         HStack {
    599             inviteThumbnail(for: invite)
    600             VStack(alignment: .leading, spacing: 2) {
    601                 Text(title).font(.body.weight(.medium))
    602                 Text("Invited by \(inviter)")
    603                     .font(.caption)
    604                     .foregroundStyle(.secondary)
    605             }
    606             Spacer()
    607             if acceptingInviteID == invite.objectID {
    608                 ProgressView()
    609             } else {
    610                 Button("Accept") { Task { await accept(invite) } }
    611                     .buttonStyle(.borderedProminent)
    612                     .controlSize(.small)
    613             }
    614             inviteMenu(for: invite)
    615         }
    616         .swipeActions(edge: .trailing) {
    617             Button("Decline") { Task { await decline(invite) } }
    618                 .tint(.gray)
    619             Button("Block", role: .destructive) { blockTarget = invite }
    620         }
    621     }
    622 
    623     private func accept(_ invite: InviteEntity) async {
    624         guard let url = invite.shareURL,
    625               let ping = invite.pingRecordName
    626         else { return }
    627         let shape = invite.gridSilhouette.flatMap(GridSilhouette.decode)
    628         acceptingInviteID = invite.objectID
    629         announcements.dismiss(id: Self.inviteErrorID)
    630         defer { acceptingInviteID = nil }
    631         do {
    632             if let onAcceptInvite {
    633                 try await onAcceptInvite(url, ping, shape)
    634             } else if let acceptInvite {
    635                 try await acceptInvite(url, ping)
    636             }
    637         } catch {
    638             announcements.post(Announcement(
    639                 id: Self.inviteErrorID,
    640                 scope: .global,
    641                 severity: .error,
    642                 title: "Accepting Failed",
    643                 body: error.localizedDescription,
    644                 dismissal: .manual
    645             ))
    646         }
    647     }
    648 
    649     /// Single-slot id for the invite-accept failure banner — a fresh
    650     /// failure replaces the prior one rather than stacking.
    651     private static let inviteErrorID = "invite-accept-error"
    652 
    653     /// Single-slot id for game-list destructive-action failures (decline,
    654     /// resign, delete) — a fresh failure replaces the prior one.
    655     private static let destructiveActionErrorID = "game-list-destructive-action-error"
    656 
    657     private func decline(_ invite: InviteEntity) async {
    658         guard let declineInvite, let gameID = invite.gameID else { return }
    659         do {
    660             try await declineInvite(gameID)
    661         } catch {
    662             announcements.post(Announcement(
    663                 id: Self.destructiveActionErrorID,
    664                 scope: .global,
    665                 severity: .error,
    666                 title: "Declining Failed",
    667                 body: error.localizedDescription,
    668                 dismissal: .manual
    669             ))
    670         }
    671     }
    672 
    673     @ViewBuilder
    674     private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View {
    675         GameRowView(
    676             game: game,
    677             shareController: shareController,
    678             usesRoomierType: usesRoomierType,
    679             onResume: { navigationPath.append(game.id) },
    680             onLeave: { leaveTarget = game },
    681             onResign: { resignTarget = game },
    682             onDelete: { deleteTarget = game }
    683         )
    684         .background(
    685             NavigationLink(value: game.id) { EmptyView() }
    686                 .opacity(0)
    687         )
    688         .swipeActions(edge: .trailing, allowsFullSwipe: false) {
    689             if !game.isOwned && game.isShared {
    690                 Button("Leave", role: .destructive) {
    691                     leaveTarget = game
    692                 }
    693             } else {
    694                 Button("Delete", role: .destructive) {
    695                     deleteTarget = game
    696                 }
    697             }
    698         }
    699     }
    700 
    701     private func leaveShare(game: GameSummary) async {
    702         do {
    703             try await shareController.leaveShare(gameID: game.id)
    704             leaveTarget = nil
    705         } catch {
    706             leaveError = error
    707             leaveTarget = nil
    708         }
    709     }
    710 
    711     private func usesRoomierType(for size: CGSize) -> Bool {
    712         size.height >= 760 && dynamicTypeSize <= .medium
    713     }
    714 }