crossmate

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

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 }