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:
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