Commit 887f90d8f2
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +58 −12
| @@ -10,6 +10,15 @@ struct ContentView: View { | ||
| 10 | 10 | @State private var selectedTab = Tabs.dashboard |
| 11 | 11 | @State private var dashboardPath = NavigationPath() |
| 12 | 12 | |
| 13 | /// Which account `dashboardPath`'s routes were resolved against. | |
| 14 | /// | |
| 15 | /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so | |
| 16 | /// this view's own state outlives an account switch while the stack | |
| 17 | /// it feeds is rebuilt. A path carried across the switch would be | |
| 18 | /// re-pushed against the new account's client — someone else's | |
| 19 | /// issue number, usually a 404. Empty while signed out. | |
| 20 | @State private var pathAccount = "" | |
| 21 | ||
| 13 | 22 | var body: some View { |
| 14 | 23 | if let client = session.client, let account = session.current { |
| 15 | 24 | TabView(selection: $selectedTab) { |
| @@ -46,31 +55,63 @@ struct ContentView: View { | ||
| 46 | 55 | } |
| 47 | 56 | } |
| 48 | 57 | } |
| 49 | .id(account.id) // fresh screens on account switch | |
| 50 | .task(id: router.pending) { await routePendingNotification() } | |
| 58 | .id(account.id) // a fresh tab bar and stacks per account | |
| 59 | .task(id: DrainKey(account: account.id, target: router.pending)) { | |
| 60 | await routePendingNotification(account) | |
| 61 | } | |
| 51 | 62 | } else { |
| 52 | 63 | NavigationStack { |
| 53 | 64 | SignInView() |
| 54 | 65 | } |
| 66 | // Signing out leaves the stale path behind in this view's | |
| 67 | // state; disowning it makes the next sign-in clear it. | |
| 68 | .task { pathAccount = "" } | |
| 55 | 69 | } |
| 56 | 70 | } |
| 57 | 71 | |
| 58 | /// Routes a tapped notification, switching accounts first when it | |
| 72 | /// What makes the drain run again. | |
| 73 | /// | |
| 74 | /// The account is in here because the cross-account pass leaves | |
| 75 | /// `pending` set on purpose, so the target alone does not change | |
| 76 | /// across the switch it performs. Keying on both means the drain | |
| 77 | /// does not depend on whether `.id()` propagates its identity | |
| 78 | /// outward through the `.task` modifier attached after it. | |
| 79 | nonisolated private struct DrainKey: Equatable, Sendable { | |
| 80 | let account: String | |
| 81 | let target: PushTarget? | |
| 82 | } | |
| 83 | ||
| 84 | /// Resets the navigation state an account switch invalidated, then | |
| 85 | /// routes a tapped notification, switching accounts first when it | |
| 59 | 86 | /// belongs to another one. |
| 60 | 87 | /// |
| 61 | /// A switch changes `account.id`, which rebuilds this whole view. | |
| 62 | /// That is why the target is left pending across it: the rebuilt | |
| 63 | /// tree runs this again and finds the account already active, so the | |
| 88 | /// A switch changes `account.id`, which rebuilds the tab bar. That | |
| 89 | /// is why the target is left pending across it: this runs again | |
| 90 | /// against the new account and finds it already active, so the | |
| 64 | 91 | /// second pass does the navigating. The drain is the mechanism, not |
| 65 | /// a workaround for it. | |
| 66 | private func routePendingNotification() async { | |
| 92 | /// a workaround for it — and it is also where the path is reset, | |
| 93 | /// because every switch comes through here whether a notification | |
| 94 | /// is pending or not. | |
| 95 | private func routePendingNotification(_ account: Account) async { | |
| 96 | if pathAccount != account.id { | |
| 97 | dashboardPath = NavigationPath() | |
| 98 | selectedTab = .dashboard | |
| 99 | pathAccount = account.id | |
| 100 | } | |
| 67 | 101 | guard let target = router.pending else { return } |
| 68 | if session.current?.id != target.accountID { | |
| 69 | guard let account = session.accounts.first(where: { $0.id == target.accountID }) else { | |
| 102 | if account.id != target.accountID { | |
| 103 | guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { | |
| 70 | 104 | router.pending = nil // signed out of it since the tap |
| 71 | 105 | return |
| 72 | 106 | } |
| 73 | _ = session.activate(account) | |
| 107 | // A token gone from the Keychain drops the account and | |
| 108 | // leaves `current` alone, so nothing would rebuild and | |
| 109 | // drain; the target would outlive every later tap, which | |
| 110 | // cannot change the key either. | |
| 111 | guard session.activate(other) else { | |
| 112 | router.pending = nil | |
| 113 | return | |
| 114 | } | |
| 74 | 115 | return // the rebuilt tree drains it |
| 75 | 116 | } |
| 76 | 117 | selectedTab = .dashboard |
| @@ -241,6 +282,11 @@ extension View { | ||
| 241 | 282 | } |
| 242 | 283 | |
| 243 | 284 | #Preview { |
| 285 | // Every environment value the signed-in branch reads, so the | |
| 286 | // preview does not depend on the machine's Keychain being empty. | |
| 287 | let session = SessionStore() | |
| 244 | 288 | ContentView() |
| 245 | .environment(SessionStore()) | |
| 289 | .environment(session) | |
| 290 | .environment(PushRouter()) | |
| 291 | .environment(PushRegistrar(session: session)) | |
| 246 | 292 | } |