crossmate

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

commit d9740405b3121ff93dd3c40636a0975fed26a356
parent cdb6cda7419b41cc07f72d2fa2324ce3506caa81
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 16:56:27 +0900

Store session notification dedup per key

This commit changes session-begin notification dedup state from one shared
UserDefaults dictionary to per-(game, author) entries. That avoids lost updates
when parallel callers record different authors at the same time, which could
make repeated session-begin sightings schedule duplicate notifications.

Existing dictionary-backed entries are still read and cleared for
compatibility, and pruning now handles both the new per-key entries and the
legacy dictionary. Author IDs are percent-encoded before being used as
defaults key components

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

Diffstat:
MShared/NotificationState.swift | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
1 file changed, 56 insertions(+), 15 deletions(-)

diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -5,9 +5,10 @@ import Foundation /// State tracked: /// - `activePuzzleID` (+ local leave-grace) — this device is viewing a puzzle, /// so notifications and the unseen-moves badge for it are skipped. -/// - `shownByGame` — a `[gameID: Date]` map used to debounce inferred -/// session notifications. Once activity for game X has been shown, further -/// session notifications for X within `dedupWindow` are suppressed. +/// - `shownByGame` — per-`(gameID, authorID)` timestamps used to debounce +/// inferred session notifications. Once activity for game X from author Y +/// has been shown, further session notifications for that pair within +/// `dedupWindow` are suppressed. /// /// `isSuppressed(gameID:)` is the unified gate; presently it is just the /// local-active check, kept under that name so callers don't need to know @@ -22,7 +23,8 @@ enum NotificationState { static let dedupWindow: TimeInterval = 20 * 60 private static let activeKey = "notif.activePuzzleID" - private static let shownKey = "notif.shownByGame" + private static let shownPrefix = "notif.shownByGame." + private static let legacyShownKey = "notif.shownByGame" private static let shownPingNamesKey = "notif.shownPingNames" private static let localActiveUntilKey = "notif.localActiveUntil" @@ -80,29 +82,33 @@ enum NotificationState { /// shown within `dedupWindow`. The key is per-author so a second /// collaborator starting a session in the same window can still notify. static func wasRecentlyShown(gameID: UUID, authorID: String, now: Date = Date()) -> Bool { - let map = shownMap() - guard let last = map[Self.compositeKey(gameID: gameID, authorID: authorID)] else { return false } + let key = shownEntryKey(gameID: gameID, authorID: authorID) + let last = defaults?.object(forKey: key) as? TimeInterval + ?? shownMap()[Self.compositeKey(gameID: gameID, authorID: authorID)] + guard let last else { return false } return now.timeIntervalSince1970 - last < dedupWindow } /// Records that a notification for `(gameID, authorID)` was surfaced (or /// would have been, before suppression decisions). Trims old entries so - /// the map stays small. + /// stored state stays small. static func recordShown(gameID: UUID, authorID: String, now: Date = Date()) { guard let defaults else { return } - var map = shownMap() - map[Self.compositeKey(gameID: gameID, authorID: authorID)] = now.timeIntervalSince1970 - let cutoff = now.timeIntervalSince1970 - 2 * dedupWindow - map = map.filter { $0.value >= cutoff } - defaults.set(map, forKey: shownKey) + defaults.set( + now.timeIntervalSince1970, + forKey: shownEntryKey(gameID: gameID, authorID: authorID) + ) + pruneShownEntries(now: now) } /// Drops the session-begin dedup entry for `(gameID, authorID)`. static func clearShown(gameID: UUID, authorID: String) { guard let defaults else { return } + defaults.removeObject(forKey: shownEntryKey(gameID: gameID, authorID: authorID)) var map = shownMap() - guard map.removeValue(forKey: Self.compositeKey(gameID: gameID, authorID: authorID)) != nil else { return } - defaults.set(map, forKey: shownKey) + if map.removeValue(forKey: Self.compositeKey(gameID: gameID, authorID: authorID)) != nil { + defaults.set(map, forKey: legacyShownKey) + } } private static func compositeKey(gameID: UUID, authorID: String) -> String { @@ -110,7 +116,42 @@ enum NotificationState { } private static func shownMap() -> [String: TimeInterval] { - defaults?.dictionary(forKey: shownKey) as? [String: TimeInterval] ?? [:] + defaults?.dictionary(forKey: legacyShownKey) as? [String: TimeInterval] ?? [:] + } + + private static func shownEntryKey(gameID: UUID, authorID: String) -> String { + "\(shownPrefix)\(gameID.uuidString).\(encodedKeyComponent(authorID))" + } + + private static let keyComponentAllowedCharacters: CharacterSet = { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-_") + return allowed + }() + + private static func encodedKeyComponent(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: keyComponentAllowedCharacters) ?? "" + } + + private static func pruneShownEntries(now: Date) { + guard let defaults else { return } + let cutoff = now.timeIntervalSince1970 - 2 * dedupWindow + for (key, value) in defaults.dictionaryRepresentation() + where key.hasPrefix(shownPrefix) { + guard let timestamp = value as? TimeInterval else { continue } + if timestamp < cutoff { + defaults.removeObject(forKey: key) + } + } + + var map = shownMap() + guard !map.isEmpty else { return } + map = map.filter { $0.value >= cutoff } + if map.isEmpty { + defaults.removeObject(forKey: legacyShownKey) + } else { + defaults.set(map, forKey: legacyShownKey) + } } /// Stamps `id` as locally active until `until`, evicting entries that