crossmate

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

commit 335c5c1f3579132a7c407f66fe14be2e073f4883
parent 8ccdffc4ed706c5293146c4d10a9e8ec6f38d48b
Author: Michael Camilleri <[email protected]>
Date:   Tue, 30 Jun 2026 09:15:16 +0900

Re-derive hidden games from blocked authors

Blocked collaborators could reappear in the Game List after a store
rebuild or on a sibling device because GameEntity.isHidden is local
state, while the block itself syncs through a block Decision. The
previous hide path only ran on the device performing the block, and it
also treated owned games as exempt.

This commit makes hidden-game state a projection of the synced block
table. Blocking or unblocking rechecks only games that feature the
changed author, and inbound Game, Moves, Player, Archive, and deletion
sync batches recheck only the touched game IDs. That keeps rebuilt or
newly-synced games hidden when they feature a blocked author without
scanning the whole library on every Game List freshen.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 20++++++++++++++++++++
MCrossmate/Services/InviteCoordinator.swift | 33+++++++--------------------------
MCrossmate/Sync/RecordApplier.swift | 21++++++++++++++++++++-
MCrossmate/Sync/SyncEngine.swift | 28+++++++++++++++++++++++++++-
MTests/Unit/Sync/FriendModelTests.swift | 47+++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 281 insertions(+), 28 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -385,6 +385,115 @@ extension GameEntity { } blockMask = Data(bytes) } + + /// Re-derives local game-list hiding from the synced block table. + /// + /// `isHidden` is intentionally local-only: block/unblock owns the durable + /// account-wide fact, and games are hidden when any known + /// collaborator on that game is currently blocked. + @discardableResult + static func reconcileBlockedFriendHiddenGames( + forAuthorIDs authorIDs: Set<String>, + in ctx: NSManagedObjectContext + ) -> Int { + let authorIDs = authorIDs.filter { !$0.isEmpty } + guard !authorIDs.isEmpty else { return 0 } + return reconcileBlockedFriendHiddenGames( + games: gamesFeaturingAnyAuthor(in: authorIDs, ctx: ctx), + blockedAuthorIDs: blockedAuthorIDs(in: ctx) + ) + } + + @discardableResult + static func reconcileBlockedFriendHiddenGames( + forGameIDs gameIDs: Set<UUID>, + in ctx: NSManagedObjectContext + ) -> Int { + guard !gameIDs.isEmpty else { return 0 } + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id IN %@", Array(gameIDs)) + return reconcileBlockedFriendHiddenGames( + games: (try? ctx.fetch(gameReq)) ?? [], + blockedAuthorIDs: blockedAuthorIDs(in: ctx) + ) + } + + private static func reconcileBlockedFriendHiddenGames( + games: [GameEntity], + blockedAuthorIDs: Set<String> + ) -> Int { + var changed = 0 + for game in games { + let authors = collaboratorAuthorIDs(for: game) + let shouldHide = !blockedAuthorIDs.isDisjoint(with: authors) + guard game.isHidden != shouldHide else { continue } + game.isHidden = shouldHide + changed += 1 + } + return changed + } + + private static func blockedAuthorIDs(in ctx: NSManagedObjectContext) -> Set<String> { + let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + blockedReq.predicate = NSPredicate(format: "isBlocked == YES") + blockedReq.propertiesToFetch = ["authorID"] + return Set(((try? ctx.fetch(blockedReq)) ?? []).compactMap(\.authorID)) + } + + private static func gamesFeaturingAnyAuthor( + in authorIDs: Set<String>, + ctx: NSManagedObjectContext + ) -> [GameEntity] { + let authors = Array(authorIDs) + var games: [GameEntity] = [] + var seen = Set<NSManagedObjectID>() + + func append(_ game: GameEntity?) { + guard let game, !seen.contains(game.objectID) else { return } + seen.insert(game.objectID) + games.append(game) + } + + let ownedReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + ownedReq.predicate = NSPredicate(format: "ckZoneOwnerName IN %@", authors) + for game in (try? ctx.fetch(ownedReq)) ?? [] { + append(game) + } + + let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + playerReq.predicate = NSPredicate(format: "authorID IN %@", authors) + for player in (try? ctx.fetch(playerReq)) ?? [] { + append(player.game) + } + + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesReq.predicate = NSPredicate(format: "authorID IN %@", authors) + for moves in (try? ctx.fetch(movesReq)) ?? [] { + append(moves.game) + } + + return games + } + + private static func collaboratorAuthorIDs(for game: GameEntity) -> Set<String> { + var authors = Set<String>() + if let owner = game.ckZoneOwnerName, !owner.isEmpty { + authors.insert(owner) + } + let moves = (game.moves as? Set<MovesEntity>) ?? [] + for move in moves { + if let authorID = move.authorID, !authorID.isEmpty { + authors.insert(authorID) + } + } + let players = (game.players as? Set<PlayerEntity>) ?? [] + for player in players { + if let authorID = player.authorID, !authorID.isEmpty { + authors.insert(authorID) + } + } + return authors + } } /// Repository over the local Core Data store. Manages the lifecycle of @@ -462,6 +571,57 @@ final class GameStore { self.eventLog = eventLog } + /// Re-applies the block-derived visibility rule to games featuring authors + /// whose block state just changed. + @discardableResult + func reconcileBlockedFriendHiddenGames(forAuthorIDs authorIDs: Set<String>) async -> Int { + let ctx = persistence.container.newBackgroundContext() + return await ctx.perform { + let changed = GameEntity.reconcileBlockedFriendHiddenGames( + forAuthorIDs: authorIDs, + in: ctx + ) + if changed > 0 { + do { + try ctx.save() + } catch { + self.eventLog?.note( + "GameStore: reconcileBlockedFriendHiddenGames save failed — \(error)", + level: "error" + ) + return 0 + } + } + return changed + } + } + + /// Re-applies the block-derived visibility rule to games that just changed + /// in sync. This catches games that arrive after their collaborator was + /// already blocked without scanning the whole library. + @discardableResult + func reconcileBlockedFriendHiddenGames(forGameIDs gameIDs: Set<UUID>) async -> Int { + let ctx = persistence.container.newBackgroundContext() + return await ctx.perform { + let changed = GameEntity.reconcileBlockedFriendHiddenGames( + forGameIDs: gameIDs, + in: ctx + ) + if changed > 0 { + do { + try ctx.save() + } catch { + self.eventLog?.note( + "GameStore: reconcileBlockedFriendHiddenGames save failed — \(error)", + level: "error" + ) + return 0 + } + } + return changed + } + } + private func saveContext(_ label: String) { do { try context.save() diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -784,6 +784,26 @@ final class AppServices { } } + await syncEngine.setOnBlockedFriendsChanged { [weak self] authorIDs in + let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames( + forAuthorIDs: authorIDs + ) ?? 0 + if hiddenChanges > 0 { + self?.syncMonitor.note("block visibility reconcile: updated \(hiddenChanges) game(s)") + } + await self?.refreshSnapshot() + } + + await syncEngine.setOnGameVisibilityCandidates { [weak self] gameIDs in + let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames( + forGameIDs: gameIDs + ) ?? 0 + if hiddenChanges > 0 { + self?.syncMonitor.note("block visibility reconcile: updated \(hiddenChanges) game(s)") + await self?.refreshSnapshot() + } + } + // A sibling device of the same iCloud account has published its read // horizon; apply it directly because SyncEngine has already accepted // the Player record under last-writer-wins freshness checks. A diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -436,7 +436,7 @@ final class InviteCoordinator { return } - await setGamesHidden(true, forAuthorID: authorID) + await reconcileGamesHiddenForBlockedFriends(authorID: authorID) // Drop their pending invites. Future inbound `.invite` Pings from a // blocked sender are caught by `consumeStaleInvites`, which deletes @@ -468,36 +468,17 @@ final class InviteCoordinator { )) return } - await setGamesHidden(false, forAuthorID: authorID) + await reconcileGamesHiddenForBlockedFriends(authorID: authorID) await refreshAppBadge("unblock friend") } - /// Hides (`true`) or reveals (`false`) the participant games the given - /// author shares with us, by flipping `GameEntity.isHidden`. Block uses this - /// instead of leaving the share, so unblock can restore the games with no - /// re-invite. Games we *own* (scope 0) are left alone, matching the prior - /// leave behaviour. A hidden game still syncs in the background; it is only - /// filtered out of the game list (`isHidden == NO`). - private func setGamesHidden(_ hidden: Bool, forAuthorID authorID: String) async { + /// Re-projects the local game-list hide flag from the durable block table. + /// Block uses this instead of leaving shares, so unblock can restore games + /// with no re-invite. + private func reconcileGamesHiddenForBlockedFriends(authorID: String) async { let ctx = persistence.container.newBackgroundContext() await ctx.perform { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "databaseScope == 1") - for game in (try? ctx.fetch(req)) ?? [] { - var authors = Set<String>() - if let owner = game.ckZoneOwnerName { authors.insert(owner) } - let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") - mReq.predicate = NSPredicate(format: "game == %@", game) - for m in (try? ctx.fetch(mReq)) ?? [] { - if let a = m.authorID { authors.insert(a) } - } - let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - pReq.predicate = NSPredicate(format: "game == %@", game) - for p in (try? ctx.fetch(pReq)) ?? [] { - if let a = p.authorID { authors.insert(a) } - } - if authors.contains(authorID) { game.isHidden = hidden } - } + _ = GameEntity.reconcileBlockedFriendHiddenGames(forAuthorIDs: [authorID], in: ctx) if ctx.hasChanges { try? ctx.save() } } } diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -49,6 +49,15 @@ struct BatchEffects { /// either side of an App Group nickname-directory entry, so the caller /// rebuilds the directory after the batch saves. var friendNamesChanged = false + /// Friend author IDs whose block Decision changed in this batch. + /// `GameEntity.isHidden` is a local projection of the block table, so the + /// app re-derives affected games once the batch has landed. + var blockedFriendAuthorIDs = Set<String>() + /// Games whose owner/player/moves metadata changed in this batch. The app + /// checks only these games against the already-synced block table so a + /// newly-arrived game featuring a blocked author is hidden without a full + /// library scan. + var visibilityCandidateGameIDs = Set<UUID>() /// Diagnostics emitted while applying the batch inside `performAndWait` — /// chiefly Core Data fetch/save failures, which silently drop records (the /// engine's change token has already advanced, so they never redeliver). @@ -82,9 +91,13 @@ extension SyncEngine { onCompletedTransition: { effects.completedTransitions.insert($0) }, onContentKeyChange: { effects.contentKeysChanged.insert($0) } ) - if let id = entity.id { effects.rosterRelevant.insert(id) } + if let id = entity.id { + effects.rosterRelevant.insert(id) + effects.visibilityCandidateGameIDs.insert(id) + } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { + effects.visibilityCandidateGameIDs.insert(value.gameID) let cellsChanged = RecordSerializer.applyMovesRecord( record, value: value, @@ -96,6 +109,7 @@ extension SyncEngine { } case "Player": if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { + effects.visibilityCandidateGameIDs.insert(gameID) self.applyPlayerRecord( record, in: ctx, @@ -109,6 +123,7 @@ extension SyncEngine { case Archive.recordType: if let id = self.applyArchiveRecord(record, in: ctx) { effects.rosterRelevant.insert(id) + effects.visibilityCandidateGameIDs.insert(id) } default: break @@ -122,6 +137,7 @@ extension SyncEngine { ) if let id = self.gameID(fromRecordName: deletion.0.recordName) { effects.rosterRelevant.insert(id) + effects.visibilityCandidateGameIDs.insert(id) } } for gameID in effects.movesUpdated { @@ -150,6 +166,9 @@ extension SyncEngine { for message in effects.traces { await trace(message) } + if let onGameVisibilityCandidates, !effects.visibilityCandidateGameIDs.isEmpty { + await onGameVisibilityCandidates(effects.visibilityCandidateGameIDs) + } if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { await onRemoteMovesUpdated(effects.movesUpdated) } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -172,6 +172,8 @@ actor SyncEngine { var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Data?)]) async -> Void)? var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)? var onAccountPushSecret: (@MainActor @Sendable (String, Int64) async -> Void)? + var onBlockedFriendsChanged: (@MainActor @Sendable (Set<String>) async -> Void)? + var onGameVisibilityCandidates: (@MainActor @Sendable (Set<UUID>) async -> Void)? private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? private var tracer: (@MainActor @Sendable (String) -> Void)? /// Fires when a delegate event reports a successful round-trip with the @@ -269,6 +271,14 @@ actor SyncEngine { onAccountPushSecret = cb } + func setOnBlockedFriendsChanged(_ cb: @MainActor @Sendable @escaping (Set<String>) async -> Void) { + onBlockedFriendsChanged = cb + } + + func setOnGameVisibilityCandidates(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { + onGameVisibilityCandidates = cb + } + func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) { localAuthorIDProvider = cb } @@ -1467,9 +1477,13 @@ actor SyncEngine { onCompletedTransition: { effects.completedTransitions.insert($0) }, onContentKeyChange: { effects.contentKeysChanged.insert($0) } ) - if let id = entity.id { effects.rosterRelevant.insert(id) } + if let id = entity.id { + effects.rosterRelevant.insert(id) + effects.visibilityCandidateGameIDs.insert(id) + } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { + effects.visibilityCandidateGameIDs.insert(value.gameID) let cellsChanged = RecordSerializer.applyMovesRecord( record, value: value, @@ -1481,6 +1495,7 @@ actor SyncEngine { } case "Player": if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { + effects.visibilityCandidateGameIDs.insert(gameID) self.applyPlayerRecord( record, in: ctx, @@ -1538,6 +1553,9 @@ actor SyncEngine { if dKind == "left", let gid = UUID(uuidString: dKey) { effects.removed.insert(gid) } + if dKind == RecordSerializer.blockDecisionKind { + effects.blockedFriendAuthorIDs.insert(dKey) + } // A friend's own rename or this user's nickname landed // — either side of an App Group nickname-directory // entry, so it's rebuilt after the batch saves. @@ -1564,6 +1582,7 @@ actor SyncEngine { // lacks it (fresh install / after the original was revoked). if let id = self.applyArchiveRecord(record, in: ctx) { effects.rosterRelevant.insert(id) + effects.visibilityCandidateGameIDs.insert(id) } default: break @@ -1577,6 +1596,7 @@ actor SyncEngine { ) if let id = self.gameID(fromRecordName: deletion.recordID.recordName) { effects.rosterRelevant.insert(id) + effects.visibilityCandidateGameIDs.insert(id) } } for gameID in effects.movesUpdated { @@ -1612,6 +1632,9 @@ actor SyncEngine { for message in effects.traces { await trace(message) } + if let onGameVisibilityCandidates, !effects.visibilityCandidateGameIDs.isEmpty { + await onGameVisibilityCandidates(effects.visibilityCandidateGameIDs) + } if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { await onRemoteMovesUpdated(effects.movesUpdated) } @@ -1655,6 +1678,9 @@ actor SyncEngine { FriendEntity.rebuildNicknameDirectory(in: mirrorCtx) } } + if !effects.blockedFriendAuthorIDs.isEmpty { + await onBlockedFriendsChanged?(effects.blockedFriendAuthorIDs) + } for id in effects.removed { if let cb = onGameRemoved { await cb(id) } } diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift @@ -60,6 +60,53 @@ struct FriendModelTests { #expect(try ctx.fetch(req).map { $0.title } == ["visible"]) } + @Test("block visibility is re-derived for any game featuring the blocked author") + func blockVisibilityReconcilesSyncedGames() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let blocked = FriendEntity(context: ctx) + blocked.authorID = "_blocked" + blocked.pairKey = "pair-blocked" + blocked.isBlocked = true + blocked.createdAt = Date() + + let shared = GameEntity(context: ctx) + shared.id = UUID() + shared.title = "Shared" + shared.puzzleSource = "" + shared.createdAt = Date() + shared.updatedAt = Date() + shared.databaseScope = 1 + shared.ckZoneOwnerName = "_blocked" + shared.isHidden = false + + let owned = GameEntity(context: ctx) + owned.id = UUID() + owned.title = "Owned" + owned.puzzleSource = "" + owned.createdAt = Date() + owned.updatedAt = Date() + owned.databaseScope = 0 + owned.ckZoneOwnerName = "_blocked" + owned.isHidden = false + + #expect(GameEntity.reconcileBlockedFriendHiddenGames( + forAuthorIDs: ["_blocked"], + in: ctx + ) == 2) + #expect(shared.isHidden) + #expect(owned.isHidden) + + blocked.isBlocked = false + #expect(GameEntity.reconcileBlockedFriendHiddenGames( + forGameIDs: [shared.id!, owned.id!], + in: ctx + ) == 2) + #expect(!shared.isHidden) + #expect(!owned.isHidden) + } + @Test("pending-status predicate and pingRecordName dedup work") func invitePredicates() throws { let persistence = makeTestPersistence()