crossmate

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

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:
MCrossmate/Views/Browse/NewGameSheet.swift | 11++++++++++-
MCrossmate/Views/Friends/FriendsView.swift | 40++++++++++++++++++++++++++++++++++++----
MCrossmate/Views/GameList/GameListView.swift | 52+++++++++++++++++++++++++++++++++++++++++++++++++---
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"