| 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 stamped(SavedInstance) // the NSE wrote the owning origin onto the banner |
| 16 | case parkedKey(SavedInstance) // SharedThreadKeys entry matched |
| 17 | case snapshot(SavedInstance) // found in a space's thread snapshot |
| 18 | case onlySpace(SavedInstance) // one signed-in space — must be it |
| 19 | case lastActive(SavedInstance) // best guess: the space this device was last in |
| 20 | case unresolved |
| 21 | } |
| 22 | |
| 23 | /// The production ladder. `origin` is the NSE's delivery-time stamp (the |
| 24 | /// banner remembers which space it came from even if the park is gone by |
| 25 | /// tap time); `lastActiveID` is the device's last-opened space, the |
| 26 | /// no-evidence fallback. `entryOrigin` and `snapshotHasThread` are seams |
| 27 | /// so the ladder's order and edge cases stay unit-testable without a |
| 28 | /// keychain (ThreadStoreTests pattern). |
| 29 | public static func resolve(threadID: String, |
| 30 | origin: String? = nil, |
| 31 | lastActiveID: String? = nil, |
| 32 | instances: [SavedInstance], |
| 33 | entryOrigin: (String) -> String? = { |
| 34 | SharedThreadKeys.lookup(threadID: $0)?.origin |
| 35 | }, |
| 36 | snapshotHasThread: (String, String) -> Bool = { origin, thread in |
| 37 | ThreadListSnapshot.load(origin: origin)? |
| 38 | .rows.contains { $0.id == thread } ?? false |
| 39 | }) -> Resolution { |
| 40 | // The stamp is delivery-time truth: the NSE resolved this thread's |
| 41 | // space when it decrypted the banner, so trust it above the (evictable) |
| 42 | // park it was read from. |
| 43 | if let origin, let inst = instances.first(where: { $0.origin == origin }) { |
| 44 | return .stamped(inst) |
| 45 | } |
| 46 | if let origin = entryOrigin(threadID), |
| 47 | let inst = instances.first(where: { $0.origin == origin }) { |
| 48 | return .parkedKey(inst) |
| 49 | } |
| 50 | // Stale or missing park: each space's last folded thread list also |
| 51 | // knows its thread ids (and survives the park being wiped). |
| 52 | if let inst = instances.first(where: { snapshotHasThread($0.origin, threadID) }) { |
| 53 | return .snapshot(inst) |
| 54 | } |
| 55 | // One signed-in space — the thread can only be there. A genuinely new |
| 56 | // thread resolves inside the chat screen's own hydrate + refresh once |
| 57 | // pushed; that beats dropping the tap on the floor. |
| 58 | let active = instances.filter { !$0.isArchived } |
| 59 | if active.count == 1 { return .onlySpace(active[0]) } |
| 60 | if instances.count == 1 { return .onlySpace(instances[0]) } |
| 61 | // Multi-space with no record anywhere (an undecryptable push for a |
| 62 | // brand-new thread): the space this device was last in is the best |
| 63 | // prior — and it's where the launch lands anyway, so a right guess |
| 64 | // (the common case) opens the chat in place, and a wrong one costs a |
| 65 | // "Chat not found" with a working back. Either beats dropping the |
| 66 | // tap on the chat list. |
| 67 | if let lastActiveID, let inst = instances.first(where: { $0.id == lastActiveID }) { |
| 68 | return .lastActive(inst) |
| 69 | } |
| 70 | return .unresolved |
| 71 | } |
| 72 | } |
| 73 | |