commit 6a72792b91d0e846a93ffb4ec88336d89ebc36b2
parent 19c73585c2e4ad2db6b5e912e5fcb213e96a4afa
Author: Michael Camilleri <[email protected]>
Date: Fri, 12 Jun 2026 10:26:06 +0900
Add a Crossmates sheet for viewing and blocking friends
The game list gains a person.2 toolbar button that presents a sheet
listing non-blocked FriendEntity rows. Blocking reuses the existing
\.blockFriend environment action and the invite-block confirmation copy.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
5 files changed, 158 insertions(+), 38 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -15,6 +15,7 @@
025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */; };
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; };
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
+ 04FA202932E8B187075CA698 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099C611C9CD5D47D89B62AD0 /* FriendsView.swift */; };
06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09D52DB46731E92C3E9297C /* EngagementStore.swift */; };
07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */; };
0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */; };
@@ -206,6 +207,7 @@
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; };
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; };
08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerPresenceGraceTests.swift; sourceTree = "<group>"; };
+ 099C611C9CD5D47D89B62AD0 /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; };
09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreContributingDevicesTests.swift; sourceTree = "<group>"; };
0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
@@ -543,6 +545,7 @@
434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */,
F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */,
EE3412F437AABD2988B6976D /* FriendPickerView.swift */,
+ 099C611C9CD5D47D89B62AD0 /* FriendsView.swift */,
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */,
5ABB557BA10CBE9909056882 /* GameShareItem.swift */,
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */,
@@ -874,6 +877,7 @@
00A25F5D8DFF62EFA0C4D1D7 /* FriendEntity+DisplayName.swift in Sources */,
886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */,
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */,
+ 04FA202932E8B187075CA698 /* FriendsView.swift in Sources */,
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */,
5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */,
128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */,
diff --git a/Crossmate/Views/AnnouncementBanner.swift b/Crossmate/Views/AnnouncementBanner.swift
@@ -5,55 +5,67 @@ import SwiftUI
/// a close affordance; transient and sticky announcements do not.
struct AnnouncementBanner: View {
let announcement: Announcement
+ /// When true the banner stretches to the height its container proposes
+ /// (PuzzleView's fixed-height header slot); by default it hugs its text,
+ /// which is what inline placements like the game list need.
+ var fillsAvailableHeight = false
let onDismiss: (() -> Void)?
var body: some View {
let severityColor = tint(for: announcement.severity)
- HStack(spacing: 0) {
- Rectangle()
- .fill(severityColor)
- .frame(width: 4)
+ HStack(alignment: .center, spacing: 8) {
+ Image(systemName: iconName(for: announcement.severity))
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(severityColor)
+ .frame(width: 24)
.accessibilityHidden(true)
- HStack(alignment: .center, spacing: 8) {
- Image(systemName: iconName(for: announcement.severity))
- .font(.footnote.weight(.semibold))
- .foregroundStyle(severityColor)
- .frame(width: 24)
- .accessibilityHidden(true)
-
- VStack(alignment: .leading, spacing: 3) {
- if let title = announcement.title, !title.isEmpty {
- Text(title)
- .font(.subheadline.weight(.semibold))
- .lineLimit(1)
- }
- Text(announcement.body)
- .font(.subheadline)
- .foregroundStyle(.primary)
- .multilineTextAlignment(.leading)
- .lineLimit(2)
+ VStack(alignment: .leading, spacing: 3) {
+ if let title = announcement.title, !title.isEmpty {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(1)
}
- .frame(maxWidth: .infinity, alignment: .leading)
+ Text(announcement.body)
+ .font(.subheadline)
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.leading)
+ .lineLimit(2)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
- if case .manual = announcement.dismissal {
- Button {
- onDismiss?()
- } label: {
- Image(systemName: "xmark.circle.fill")
- .font(.callout)
- .symbolRenderingMode(.hierarchical)
- }
- .buttonStyle(.plain)
- .foregroundStyle(.secondary)
- .accessibilityLabel("Dismiss announcement")
+ if case .manual = announcement.dismissal {
+ Button {
+ onDismiss?()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.callout)
+ .symbolRenderingMode(.hierarchical)
}
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel("Dismiss announcement")
}
- .padding(.horizontal, 10)
- .padding(.vertical, 6)
}
- .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .padding(.leading, 4)
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: fillsAvailableHeight ? .infinity : nil,
+ alignment: .leading
+ )
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ // The severity rail is drawn as an overlay rather than laid out as an
+ // HStack child: a width-constrained `Rectangle` in the HStack is
+ // greedy for height, which made the whole banner expand to fill
+ // whatever its container offered instead of hugging the text.
+ .overlay(alignment: .leading) {
+ Rectangle()
+ .fill(severityColor)
+ .frame(width: 4)
+ .accessibilityHidden(true)
+ }
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
diff --git a/Crossmate/Views/FriendsView.swift b/Crossmate/Views/FriendsView.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+
+/// Sheet listing the user's crossmates (friends), presented from the game
+/// list. Friends are accumulated automatically the first time you collaborate
+/// with someone (see `FriendController`); the only action in v1 is blocking,
+/// which tears down the pairwise channel via `\.blockFriend`.
+struct FriendsView: View {
+ @Environment(\.blockFriend) private var blockFriend
+ @Environment(\.dismiss) private var dismiss
+
+ @FetchRequest(
+ sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)],
+ predicate: NSPredicate(format: "isBlocked == NO"),
+ animation: .default
+ )
+ private var friends: FetchedResults<FriendEntity>
+
+ @State private var blockTarget: FriendEntity?
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(friends, id: \.authorID) { friend in
+ friendRow(for: friend)
+ }
+ }
+ .overlay {
+ if friends.isEmpty {
+ ContentUnavailableView {
+ Label("No Crossmates", systemImage: "person.2")
+ } description: {
+ Text("Crossmates are added automatically when you start a shared puzzle with someone.")
+ }
+ }
+ }
+ .navigationTitle("Crossmates")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "xmark")
+ }
+ .accessibilityLabel("Cancel")
+ }
+ }
+ .alert("Block This Player?", isPresented: .init(
+ get: { blockTarget != nil },
+ set: { if !$0 { blockTarget = nil } }
+ )) {
+ Button("Block", role: .destructive) {
+ if let authorID = blockTarget?.authorID {
+ Task { await blockFriend?(authorID) }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ let name = blockTarget?.resolvedDisplayName ?? "this player"
+ Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func friendRow(for friend: FriendEntity) -> some View {
+ HStack {
+ FriendAvatarView(authorID: friend.authorID ?? "")
+ .padding(.trailing, 8)
+ Text(friend.resolvedDisplayName)
+ Spacer()
+ Menu {
+ Button(role: .destructive) { blockTarget = friend } label: {
+ Label("Block", systemImage: "hand.raised")
+ }
+ } label: {
+ Image(systemName: "ellipsis")
+ .font(.body)
+ .frame(width: 32, height: 32)
+ .contentShape(Rectangle())
+ }
+ .tint(.secondary)
+ .compositingGroup()
+ }
+ .swipeActions(edge: .trailing) {
+ Button("Block", role: .destructive) { blockTarget = friend }
+ }
+ }
+}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -41,6 +41,7 @@ struct GameListView: View {
@State private var showingNewGame = false
@State private var showingSettings = false
+ @State private var showingFriends = false
@State private var deleteTarget: GameSummary?
@State private var resignTarget: GameSummary?
@State private var leaveTarget: GameSummary?
@@ -77,6 +78,14 @@ struct GameListView: View {
}
ToolbarItem(placement: .topBarTrailing) {
Button {
+ showingFriends = true
+ } label: {
+ Image(systemName: "person.2")
+ }
+ }
+ ToolbarSpacer(.fixed, placement: .topBarTrailing)
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
showingNewGame = true
} label: {
Image(systemName: "plus")
@@ -86,6 +95,9 @@ struct GameListView: View {
.sheet(isPresented: $showingSettings) {
SettingsView()
}
+ .sheet(isPresented: $showingFriends) {
+ FriendsView()
+ }
.sheet(isPresented: $showingNewGame) {
NewGameSheet(store: store) { gameID in
navigationPath.append(gameID)
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -1231,7 +1231,10 @@ private struct PuzzleHeader: View {
// branches occupy the same fixed-height frame so the grid
// below doesn't jump.
if let announcement = visibleAnnouncement {
- AnnouncementBanner(announcement: announcement) {
+ AnnouncementBanner(
+ announcement: announcement,
+ fillsAvailableHeight: true
+ ) {
announcements.dismiss(id: announcement.id)
}
.padding(.horizontal, 12)