| 1 | import Foundation |
| 2 | |
| 3 | // 🤖 Resolves a tapped notification's thread id to the space that owns it. |
| 4 | // The tap route used to hang everything on one source — the SharedThreadKeys |
| 5 | // entry the ThreadStore folds park — and bailed SILENTLY when it was missing |
| 6 | // or its origin no longer matched a saved instance (stale park, re-added |
| 7 | // space, wiped keychain): the app cold-launched to the chat list and the tap |
| 8 | // just… did nothing (the 2026-07-28 report). Resolution now falls back down |
| 9 | // the same ladder the data actually lives in, and the caller can always tell |
| 10 | // why a tap still couldn't route. |
| 11 | public enum NotificationRoute { |
| 12 | /// Where a resolution came from — the caller logs it so a stranded tap in |
| 13 | /// the field names its failing rung instead of vanishing. |
| 14 | public enum Resolution { |
| 15 | case parkedKey(SavedInstance) // SharedThreadKeys entry matched |
| 16 | case snapshot(SavedInstance) // found in a space's thread snapshot |
| 17 | case onlySpace(SavedInstance) // one signed-in space — must be it |
| 18 | case unresolved |
| 19 | } |
| 20 | |
| 21 | /// The production ladder. `entryOrigin` and `snapshotHasThread` are seams |
| 22 | /// so the ladder's order and edge cases stay unit-testable without a |
| 23 | /// keychain (ThreadStoreTests pattern). |
| 24 | public static func resolve(threadID: String, |
| 25 | instances: [SavedInstance], |
| 26 | entryOrigin: (String) -> String? = { |
| 27 | SharedThreadKeys.lookup(threadID: $0)?.origin |
| 28 | }, |
| 29 | snapshotHasThread: (String, String) -> Bool = { origin, thread in |
| 30 | ThreadListSnapshot.load(origin: origin)? |
| 31 | .rows.contains { $0.id == thread } ?? false |
| 32 | }) -> Resolution { |
| 33 | if let origin = entryOrigin(threadID), |
| 34 | let inst = instances.first(where: { $0.origin == origin }) { |
| 35 | return .parkedKey(inst) |
| 36 | } |
| 37 | // Stale or missing park: each space's last folded thread list also |
| 38 | // knows its thread ids (and survives the park being wiped). |
| 39 | if let inst = instances.first(where: { snapshotHasThread($0.origin, threadID) }) { |
| 40 | return .snapshot(inst) |
| 41 | } |
| 42 | // One signed-in space — the thread can only be there. A genuinely new |
| 43 | // thread resolves inside the chat screen's own hydrate + refresh once |
| 44 | // pushed; that beats dropping the tap on the floor. |
| 45 | let active = instances.filter { !$0.isArchived } |
| 46 | if active.count == 1 { return .onlySpace(active[0]) } |
| 47 | if instances.count == 1 { return .onlySpace(instances[0]) } |
| 48 | return .unresolved |
| 49 | } |
| 50 | } |
| 51 | |