crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Views/AnnouncementBanner.swift | 86+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
ACrossmate/Views/FriendsView.swift | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameListView.swift | 12++++++++++++
MCrossmate/Views/PuzzleView.swift | 5++++-
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)