crossmate

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

commit bbd5aff86d49bac858afcffe917052320bfa30ba
parent 87a34db1e8a7643fa858cf371ce38ac368da7117
Author: Michael Camilleri <[email protected]>
Date:   Mon, 15 Jun 2026 18:51:07 +0900

Preview the grid on internal invitations

A friend invited from within the app landed in the Game List's Invited
section as a bare title and inviter line, with no sense of the puzzle
being offered — unlike a shared link, whose silhouette segment already
lets the recipient see the grid's shape before any join. The two
invitation paths previewed the same puzzle differently.

This commit carries the grid silhouette along the friend-zone invite as
well, so the Invited row paints a thumbnail of the puzzle's shape. The
inviter encodes the layout with the same GridSilhouette segment a share
link uses — reusing one wire format across both channels — and the
segment travels inside the existing InvitePayload string, so the Ping
schema is untouched. The recipient persists it on the durable
InviteEntity and decodes it when rendering the row; open cells draw grey
to read as 'not yet playable', matching the link-tap placeholder.
Non-square grids encode to nothing and simply get no preview, and an
invite from an older sender — whose payload omits the field — decodes to
a nil silhouette and falls back to the title-only row.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Services/InviteCoordinator.swift | 12+++++++++++-
MCrossmate/Sync/FriendController.swift | 12+++++++++---
MCrossmate/Sync/FriendZone.swift | 14++++++++++++++
MCrossmate/Views/GameListView.swift | 18++++++++++++++++++
MTests/Unit/Sync/FriendZoneTests.swift | 20++++++++++++++++++++
6 files changed, 73 insertions(+), 4 deletions(-)

diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -115,6 +115,7 @@ <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="gameID" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="gameTitle" optional="YES" attributeType="String" defaultValueString=""/> + <attribute name="gridSilhouette" optional="YES" attributeType="String"/> <attribute name="inviterAuthorID" attributeType="String"/> <attribute name="inviterName" optional="YES" attributeType="String" defaultValueString=""/> <attribute name="pingRecordName" attributeType="String"/> diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -80,6 +80,14 @@ final class InviteCoordinator { req.fetchLimit = 1 let title = (try? ctx.fetch(req).first)?.title ?? "" + // Encode the grid silhouette the same way share links do, so the + // recipient can preview the puzzle in their "Invited" row. `nil` for + // non-square grids, which simply get no preview. + let shape = shareController.gridSilhouette(for: gameID) + let silhouette = shape.flatMap { + GridSilhouette.encode(side: $0.side, blocks: $0.blocks) + } + let url = try await shareController.addFriendParticipant( toGameID: gameID, userRecordName: friendAuthorID @@ -90,7 +98,8 @@ final class InviteCoordinator { gameTitle: title, inviterAuthorID: localAuthorID, inviterName: preferences.name, - gameShareURL: url + gameShareURL: url, + gridSilhouette: silhouette ) } @@ -185,6 +194,7 @@ final class InviteCoordinator { invite.inviterAuthorID = ping.authorID invite.inviterName = ping.playerName invite.shareURL = payload.gameShareURL + invite.gridSilhouette = payload.gridSilhouette invite.pingRecordName = ping.recordName invite.status = "pending" invite.createdAt = Date() diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -241,14 +241,17 @@ final class FriendController { /// Writes an `.invite` Ping carrying the game's share URL into the friend /// zone. The friend must already be added as a participant on the game's /// `CKShare` (the caller does that via `ShareController` and passes the - /// resulting URL in). No-ops for an unknown or blocked friend. + /// resulting URL in). No-ops for an unknown or blocked friend. The optional + /// `gridSilhouette` is a `GridSilhouette`-encoded segment that lets the + /// recipient's "Invited" row preview the puzzle's shape. func sendInvite( toFriendAuthorID friendAuthorID: String, gameID: UUID, gameTitle: String, inviterAuthorID: String, inviterName: String, - gameShareURL: URL + gameShareURL: URL, + gridSilhouette: String? = nil ) async throws { let ctx = persistence.viewContext let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") @@ -262,7 +265,10 @@ final class FriendController { let ownerName = friend.friendZoneOwnerName else { throw FriendError.friendNotFound } - let payload = FriendZone.InvitePayload(gameShareURL: gameShareURL.absoluteString) + let payload = FriendZone.InvitePayload( + gameShareURL: gameShareURL.absoluteString, + gridSilhouette: gridSilhouette + ) guard let encoded = payload.encodedString() else { throw FriendError.payloadEncodingFailed } diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift @@ -66,6 +66,20 @@ enum FriendZone { /// section without an out-of-band link. struct InvitePayload: Codable, Equatable { let gameShareURL: String + /// The game's grid silhouette, as a `GridSilhouette`-encoded segment, + /// so the recipient's "Invited" row can preview the puzzle's shape + /// without a CloudKit round-trip — the internal-invite counterpart to + /// the silhouette segment carried in share links. `nil` for non-square + /// grids (which get no preview) and for invites from older senders. + let gridSilhouette: String? + + // An explicit init keeps `gridSilhouette` out of the inline default + // (which would exclude it from Codable) while letting existing call + // sites omit it; a missing JSON key decodes to `nil`. + init(gameShareURL: String, gridSilhouette: String? = nil) { + self.gameShareURL = gameShareURL + self.gridSilhouette = gridSilhouette + } func encodedString() -> String? { guard let data = try? JSONEncoder().encode(self) else { return nil } diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -477,11 +477,28 @@ struct GameListView: View { ) } + /// The puzzle-shape preview for an invite, decoded from the silhouette + /// segment the inviter sent. Open cells render grey (`.filled`) to read as + /// "not yet playable", matching the link-tap placeholder in + /// `JoiningPuzzleView`. Absent or non-square grids get no thumbnail. + @ViewBuilder + private func inviteThumbnail(for invite: InviteEntity) -> some View { + if let segment = invite.gridSilhouette, + let shape = GridSilhouette.decode(segment) { + GridThumbnailView( + width: shape.side, + height: shape.side, + cells: shape.blocks.map { $0 ? .block : .filled } + ) + } + } + @ViewBuilder private func inviteCard(for invite: InviteEntity) -> some View { let inviter = invite.resolvedInviterName ?? "A player" let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" HStack(spacing: 12) { + inviteThumbnail(for: invite) VStack(alignment: .leading, spacing: 2) { Text(title) .font(.subheadline.weight(.semibold)) @@ -534,6 +551,7 @@ struct GameListView: View { let inviter = invite.resolvedInviterName ?? "A player" let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" HStack { + inviteThumbnail(for: invite) VStack(alignment: .leading, spacing: 2) { Text(title).font(.body.weight(.medium)) Text("Invited by \(inviter)") diff --git a/Tests/Unit/Sync/FriendZoneTests.swift b/Tests/Unit/Sync/FriendZoneTests.swift @@ -87,6 +87,26 @@ struct FriendZoneTests { #expect(FriendZone.InvitePayload.decode(encoded) == payload) } + @Test("InvitePayload round-trips a grid silhouette segment") + func invitePayloadRoundTripWithSilhouette() { + let payload = FriendZone.InvitePayload( + gameShareURL: "https://www.icloud.com/share/xyz#abc", + gridSilhouette: "sf//AA" + ) + let decoded = FriendZone.InvitePayload.decode(payload.encodedString()) + #expect(decoded == payload) + #expect(decoded?.gridSilhouette == "sf//AA") + } + + @Test("InvitePayload from an older sender decodes with a nil silhouette") + func invitePayloadDecodesLegacyWithoutSilhouette() { + // Payloads written before the silhouette field omit the key entirely. + let legacy = #"{"gameShareURL":"https://www.icloud.com/share/xyz"}"# + let decoded = FriendZone.InvitePayload.decode(legacy) + #expect(decoded?.gameShareURL == "https://www.icloud.com/share/xyz") + #expect(decoded?.gridSilhouette == nil) + } + @Test("InvitePayload.decode tolerates nil and malformed input") func invitePayloadDecodeTolerant() { #expect(FriendZone.InvitePayload.decode(nil) == nil)