commit 691272b99f517663126263bd68e4c0fe8c71bfb7
parent 0e718bdf64b39455c798f1bfcb6d96b909861cc6
Author: Michael Camilleri <[email protected]>
Date: Mon, 29 Jun 2026 09:05:37 +0900
Support beginning puzzles from Friends menu
This commit lets the user begin a new shared puzzle directly from the
Game List's Friends menu. Tapping a friend's row, or choosing
Collaborate from the row menu, now closes the sheet, opens the puzzle
picker, and carries that friend through the normal puzzle-creation flow.
Once the puzzle is created, Crossmate opens it immediately and sends the
existing direct friend invite in the background. Invite failures stay
scoped to the new puzzle with a retry hint for the Share menu, while the
underlying error is written to the Diagnostics Log.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 95 insertions(+), 8 deletions(-)
diff --git a/Crossmate/Views/Browse/NewGameSheet.swift b/Crossmate/Views/Browse/NewGameSheet.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct NewGameSheet: View {
let store: GameStore
+ var inviteTargetName: String? = nil
var onCreated: (UUID) -> Void = { _ in }
@Environment(\.dismiss) private var dismiss
@@ -58,7 +59,7 @@ struct NewGameSheet: View {
}
}
}
- .navigationTitle("New Puzzle")
+ .navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -109,6 +110,14 @@ struct NewGameSheet: View {
}
}
+ private var navigationTitle: String {
+ if let inviteTargetName, !inviteTargetName.isEmpty {
+ "New Puzzle with \(inviteTargetName)"
+ } else {
+ "New Puzzle"
+ }
+ }
+
private func handleSelected(_ source: String) {
if store.findGameID(matching: source) != nil {
duplicateSource = source
diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift
@@ -1,11 +1,20 @@
import SwiftUI
+struct FriendNewGameTarget: Identifiable, Equatable {
+ let authorID: String
+ let displayName: String
+
+ var id: String { authorID }
+}
+
/// 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 actions are renaming (a private
/// nickname via `\.renameFriend`) and blocking, which tears down the pairwise
/// channel via `\.blockFriend`.
struct FriendsView: View {
+ var onStartGame: (FriendNewGameTarget) -> Void = { _ in }
+
@Environment(\.blockFriend) private var blockFriend
@Environment(\.renameFriend) private var renameFriend
@Environment(\.dismiss) private var dismiss
@@ -82,13 +91,36 @@ struct FriendsView: View {
@ViewBuilder
private func friendRow(for friend: FriendEntity) -> some View {
+ let authorID = friend.authorID ?? ""
+ let displayName = friend.resolvedDisplayName
+
HStack {
- FriendAvatarView(authorID: friend.authorID ?? "")
- .padding(.trailing, 8)
- Text(friend.resolvedDisplayName)
- Spacer()
+ Button {
+ guard !authorID.isEmpty else { return }
+ onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName))
+ dismiss()
+ } label: {
+ HStack {
+ FriendAvatarView(authorID: authorID)
+ .padding(.trailing, 8)
+ Text(displayName)
+ .foregroundStyle(.primary)
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(authorID.isEmpty)
+
Menu {
Button {
+ guard !authorID.isEmpty else { return }
+ onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName))
+ dismiss()
+ } label: {
+ Label("Collaborate", systemImage: "plus.square")
+ }
+ Button {
renameText = friend.nickname ?? ""
renameTarget = friend
} label: {
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -36,9 +36,11 @@ struct GameListView: View {
@Environment(\.acceptInvite) private var acceptInvite
@Environment(\.declineInvite) private var declineInvite
@Environment(\.blockFriend) private var blockFriend
+ @Environment(\.inviteFriend) private var inviteFriend
@Environment(\.sendResignPings) private var sendResignPings
@Environment(PlayerPreferences.self) private var preferences
@Environment(AnnouncementCenter.self) private var announcements
+ @Environment(EventLog.self) private var eventLog
@Environment(TipStore.self) private var tips
@State private var acceptingInviteID: NSManagedObjectID?
@State private var blockTarget: InviteEntity?
@@ -46,6 +48,8 @@ struct GameListView: View {
@State private var showingNewGame = false
@State private var showingSettings = false
@State private var showingFriends = false
+ @State private var newGameInviteTarget: FriendNewGameTarget?
+ @State private var queuedNewGameInviteTarget: FriendNewGameTarget?
@State private var deleteTarget: GameSummary?
@State private var resignTarget: GameSummary?
@State private var leaveTarget: GameSummary?
@@ -111,6 +115,7 @@ struct GameListView: View {
}
ToolbarItem(placement: .topBarTrailing) {
Button {
+ newGameInviteTarget = nil
showingNewGame = true
} label: {
Image(systemName: "plus")
@@ -120,12 +125,20 @@ struct GameListView: View {
.sheet(isPresented: $showingSettings) {
SettingsView()
}
- .sheet(isPresented: $showingFriends) {
- FriendsView()
+ .sheet(isPresented: $showingFriends, onDismiss: presentQueuedFriendNewGame) {
+ FriendsView { target in
+ queuedNewGameInviteTarget = target
+ }
}
.sheet(isPresented: $showingNewGame) {
- NewGameSheet(store: store) { gameID in
+ NewGameSheet(
+ store: store,
+ inviteTargetName: newGameInviteTarget?.displayName
+ ) { gameID in
navigationPath.append(gameID)
+ if let target = newGameInviteTarget {
+ Task { await inviteNewGame(gameID, to: target) }
+ }
}
}
.task {
@@ -255,6 +268,34 @@ struct GameListView: View {
}
}
+ private func presentQueuedFriendNewGame() {
+ guard let target = queuedNewGameInviteTarget else { return }
+ queuedNewGameInviteTarget = nil
+ newGameInviteTarget = target
+ showingNewGame = true
+ }
+
+ private func inviteNewGame(_ gameID: UUID, to target: FriendNewGameTarget) async {
+ guard let inviteFriend else { return }
+ announcements.dismiss(id: Self.newGameInviteErrorID)
+ do {
+ try await inviteFriend(gameID, target.authorID)
+ } catch {
+ eventLog.note(
+ "new game friend invite failed game=\(gameID.uuidString) friend=\(target.authorID): \(error)",
+ level: "error"
+ )
+ announcements.post(Announcement(
+ id: Self.newGameInviteErrorID,
+ scope: .game(gameID),
+ severity: .error,
+ title: "Inviting Failed",
+ body: "\(target.displayName) could not be invited. Try again from the Share menu.",
+ dismissal: .manual
+ ))
+ }
+ }
+
@ViewBuilder
private func content(usesRoomierType: Bool) -> some View {
let summaries = games.compactMap {
@@ -650,6 +691,11 @@ struct GameListView: View {
/// failure replaces the prior one rather than stacking.
private static let inviteErrorID = "invite-accept-error"
+ /// Single-slot id for failures after starting a puzzle from the Crossmates
+ /// sheet — scoped to the new puzzle, where the user can retry from its
+ /// Share menu.
+ private static let newGameInviteErrorID = "new-game-friend-invite-error"
+
/// Single-slot id for game-list destructive-action failures (decline,
/// resign, delete) — a fresh failure replaces the prior one.
private static let destructiveActionErrorID = "game-list-destructive-action-error"