commit 338bc54aaa463dddafa63206a878c382bd28ad51
parent 335c5c1f3579132a7c407f66fe14be2e073f4883
Author: Michael Camilleri <[email protected]>
Date: Tue, 30 Jun 2026 11:16:58 +0900
Add a Settings unblock surface
Blocked friends were hidden from the Crossmates list, and the
unblockFriend action was already wired, but the user had no in-app
surface for reversing a block. This commit adds a 'Sharing' section to
Settings with a 'Blocked Users' screen that lists blocked friends and
confirms before restoring them.
The screen reuses unblockFriend, so unblocking keeps the existing
CloudKit permission restore and hidden-game reprojection path. The Game
List's blocked-friend fetch is also documented as the guard that keeps
pending invites from blocked crossmates out of the 'Invited' section
while cleanup catches up.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 75 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -33,6 +33,9 @@ struct GameListView: View {
sortDescriptors: [],
predicate: NSPredicate(format: "isBlocked == YES")
)
+ // Drives the Invited section filter below. Pending invites from blocked
+ // crossmates can exist briefly until the ping cleanup path consumes them,
+ // but the library and badge state should never surface them.
private var blockedFriends: FetchedResults<FriendEntity>
@Environment(\.acceptInvite) private var acceptInvite
diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift
@@ -78,6 +78,12 @@ struct SettingsView: View {
Text("Receive notifications when friends nudge you, pause playing after making changes, invite you to play, join one of your puzzles or finish a puzzle. These settings apply only to this device.")
}
+ Section("Sharing") {
+ NavigationLink("Blocked Users") {
+ BlockedUsersView()
+ }
+ }
+
if debugMode {
Section("Debugging") {
Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled)
@@ -212,6 +218,72 @@ struct SettingsView: View {
}
}
+private struct BlockedUsersView: View {
+ @Environment(\.unblockFriend) private var unblockFriend
+
+ @FetchRequest(
+ sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)],
+ predicate: NSPredicate(format: "isBlocked == YES"),
+ animation: .default
+ )
+ private var blockedFriends: FetchedResults<FriendEntity>
+
+ @State private var unblockTarget: FriendEntity?
+
+ var body: some View {
+ List {
+ ForEach(blockedFriends, id: \.authorID) { friend in
+ blockedFriendRow(for: friend)
+ }
+ }
+ .overlay {
+ if blockedFriends.isEmpty {
+ ContentUnavailableView {
+ Label("No Blocked Users", systemImage: "person.crop.circle.badge.xmark")
+ } description: {
+ Text("Blocked crossmates will appear here.")
+ }
+ }
+ }
+ .navigationTitle("Blocked Users")
+ .navigationBarTitleDisplayMode(.inline)
+ .alert("Unblock This Player?", isPresented: .init(
+ get: { unblockTarget != nil },
+ set: { if !$0 { unblockTarget = nil } }
+ )) {
+ Button("Unblock") {
+ guard let authorID = unblockTarget?.authorID, !authorID.isEmpty else { return }
+ Task { await unblockFriend?(authorID) }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ let name = unblockTarget?.resolvedDisplayName ?? "this player"
+ Text("\(name) will be able to invite you again, and any puzzles hidden when you blocked them will return to your list.")
+ }
+ }
+
+ @ViewBuilder
+ private func blockedFriendRow(for friend: FriendEntity) -> some View {
+ let authorID = friend.authorID ?? ""
+
+ HStack {
+ FriendAvatarView(authorID: authorID)
+ .padding(.trailing, 8)
+ Text(friend.resolvedDisplayName)
+ .foregroundStyle(.primary)
+ Spacer()
+ Button("Unblock") {
+ unblockTarget = friend
+ }
+ .disabled(authorID.isEmpty)
+ }
+ .swipeActions(edge: .trailing) {
+ Button("Unblock") { unblockTarget = friend }
+ .tint(.blue)
+ }
+ }
+}
+
private struct ProfileColorEditView: View {
@Environment(PlayerPreferences.self) private var preferences
@Environment(\.dismiss) private var dismiss