Commit 887f90d8f2
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +58 −12
| @@ -10,6 +10,15 @@ struct ContentView: View { | |||
| 10 | @State private var selectedTab = Tabs.dashboard | 10 | @State private var selectedTab = Tabs.dashboard |
| 11 | @State private var dashboardPath = NavigationPath() | 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 | var body: some View { | 22 | var body: some View { |
| 14 | if let client = session.client, let account = session.current { | 23 | if let client = session.client, let account = session.current { |
| 15 | TabView(selection: $selectedTab) { | 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 | 58 | .id(account.id) // a fresh tab bar and stacks per account |
| 50 | .task(id: router.pending) { await routePendingNotification() } | 59 | .task(id: DrainKey(account: account.id, target: router.pending)) { |
| 60 | await routePendingNotification(account) | ||
| 61 | } | ||
| 51 | } else { | 62 | } else { |
| 52 | NavigationStack { | 63 | NavigationStack { |
| 53 | SignInView() | 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 | /// belongs to another one. | 86 | /// belongs to another one. |
| 60 | /// | 87 | /// |
| 61 | /// A switch changes `account.id`, which rebuilds this whole view. | 88 | /// A switch changes `account.id`, which rebuilds the tab bar. That |
| 62 | /// That is why the target is left pending across it: the rebuilt | 89 | /// is why the target is left pending across it: this runs again |
| 63 | /// tree runs this again and finds the account already active, so the | 90 | /// against the new account and finds it already active, so the |
| 64 | /// second pass does the navigating. The drain is the mechanism, not | 91 | /// second pass does the navigating. The drain is the mechanism, not |
| 65 | /// a workaround for it. | 92 | /// a workaround for it — and it is also where the path is reset, |
| 66 | private func routePendingNotification() async { | 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 | guard let target = router.pending else { return } | 101 | guard let target = router.pending else { return } |
| 68 | if session.current?.id != target.accountID { | 102 | if account.id != target.accountID { |
| 69 | guard let account = session.accounts.first(where: { $0.id == target.accountID }) else { | 103 | guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { |
| 70 | router.pending = nil // signed out of it since the tap | 104 | router.pending = nil // signed out of it since the tap |
| 71 | return | 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 | return // the rebuilt tree drains it | 115 | return // the rebuilt tree drains it |
| 75 | } | 116 | } |
| 76 | selectedTab = .dashboard | 117 | selectedTab = .dashboard |
| @@ -241,6 +282,11 @@ extension View { | |||
| 241 | } | 282 | } |
| 242 | 283 | ||
| 243 | #Preview { | 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 | ContentView() | 288 | ContentView() |
| 245 | .environment(SessionStore()) | 289 | .environment(session) |
| 290 | .environment(PushRouter()) | ||
| 291 | .environment(PushRegistrar(session: session)) | ||
| 246 | } | 292 | } |