crossmate

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

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 }