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