FriendsView.swift (6606B)
1 import CoreData 2 import SwiftUI 3 4 struct FriendNewGameTarget: Identifiable, Equatable { 5 let authorID: String 6 let displayName: String 7 8 var id: String { authorID } 9 } 10 11 /// Sheet listing the user's non-blocked friends, presented from 12 /// the game list. Friends are accumulated automatically the first time you 13 /// collaborate with someone (see `FriendController`); the actions are renaming 14 /// (a private nickname) and blocking (a reversible, server-enforced read-only 15 /// downgrade on the inbox we own). Blocked friends are intentionally hidden 16 /// from this list; unblocking lives on Settings' Blocked Users screen. 17 struct FriendsView: View { 18 var onStartGame: (FriendNewGameTarget) -> Void = { _ in } 19 20 @Environment(\.appActions) private var appActions 21 @Environment(\.dismiss) private var dismiss 22 23 @FetchRequest( 24 sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], 25 predicate: NSPredicate(format: "isBlocked == NO") 26 ) 27 private var friends: FetchedResults<FriendEntity> 28 29 @State private var blockTarget: FriendEntity? 30 @State private var renameTarget: FriendEntity? 31 @State private var renameText = "" 32 33 var body: some View { 34 NavigationStack { 35 List { 36 ForEach(friends, id: \.authorID) { friend in 37 friendRow(for: friend) 38 } 39 } 40 .overlay { 41 if friends.isEmpty { 42 ContentUnavailableView { 43 Label("No Crossmates", systemImage: "person.2") 44 } description: { 45 Text("Crossmates are added automatically when you start a shared puzzle with someone.") 46 } 47 } 48 } 49 .navigationTitle("Crossmates") 50 .navigationBarTitleDisplayMode(.inline) 51 .toolbar { 52 ToolbarItem(placement: .cancellationAction) { 53 Button { 54 dismiss() 55 } label: { 56 Image(systemName: "xmark") 57 } 58 .accessibilityLabel("Cancel") 59 } 60 } 61 .alert("Rename This Player?", isPresented: .init( 62 get: { renameTarget != nil }, 63 set: { if !$0 { renameTarget = nil } } 64 )) { 65 TextField("Name", text: $renameText) 66 Button("Rename") { 67 if let authorID = renameTarget?.authorID { 68 let nickname = renameText 69 Task { await appActions?.renameFriend(authorID: authorID, nickname: nickname) } 70 } 71 } 72 Button("Cancel", role: .cancel) {} 73 } message: { 74 Text("Only you will see this name. Leave it blank to use the name they chose.") 75 } 76 .alert("Block This Player?", isPresented: .init( 77 get: { blockTarget != nil }, 78 set: { if !$0 { blockTarget = nil } } 79 )) { 80 Button("Block", role: .destructive) { 81 if let authorID = blockTarget?.authorID { 82 Task { await appActions?.blockFriend(authorID: authorID) } 83 } 84 } 85 Button("Cancel", role: .cancel) {} 86 } message: { 87 let name = blockTarget?.resolvedDisplayName ?? "this player" 88 Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be hidden from your list. You can unblock them later to bring everything back.") 89 } 90 } 91 } 92 93 @ViewBuilder 94 private func friendRow(for friend: FriendEntity) -> some View { 95 let authorID = friend.authorID ?? "" 96 let displayName = friend.resolvedDisplayName 97 98 HStack { 99 Button { 100 guard !authorID.isEmpty else { return } 101 onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName)) 102 dismiss() 103 } label: { 104 HStack { 105 FriendAvatarView(authorID: authorID) 106 .padding(.trailing, 8) 107 Text(displayName) 108 .foregroundStyle(.primary) 109 Spacer() 110 } 111 .contentShape(Rectangle()) 112 } 113 .buttonStyle(.plain) 114 .disabled(authorID.isEmpty) 115 116 Menu { 117 Button { 118 guard !authorID.isEmpty else { return } 119 onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName)) 120 dismiss() 121 } label: { 122 Label("Invite", systemImage: "plus.square") 123 } 124 Button { 125 renameText = friend.nickname ?? "" 126 renameTarget = friend 127 } label: { 128 Label("Rename", systemImage: "character.cursor.ibeam") 129 } 130 Button(role: .destructive) { blockTarget = friend } label: { 131 Label("Block", systemImage: "hand.raised") 132 } 133 } label: { 134 Image(systemName: "ellipsis") 135 .font(.body) 136 .frame(width: 32, height: 32) 137 .contentShape(Rectangle()) 138 } 139 .tint(.secondary) 140 .compositingGroup() 141 } 142 .swipeActions(edge: .trailing) { 143 Button("Block", role: .destructive) { blockTarget = friend } 144 } 145 .accessibilityElement(children: .ignore) 146 .accessibilityLabel(displayName) 147 .accessibilityHint("Starts a new puzzle with this crossmate") 148 .accessibilityAddTraits(.isButton) 149 .accessibilityAction { 150 guard !authorID.isEmpty else { return } 151 onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName)) 152 dismiss() 153 } 154 .accessibilityActions { 155 Button("Invite") { 156 guard !authorID.isEmpty else { return } 157 onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName)) 158 dismiss() 159 } 160 Button("Rename") { 161 renameText = friend.nickname ?? "" 162 renameTarget = friend 163 } 164 Button("Block", role: .destructive) { 165 blockTarget = friend 166 } 167 } 168 } 169 }