gitbay/ContentView.swift

v1.7.0
gitbay-ios/gitbay/ContentView.swift history · blame · raw

428 lines · 17444 bytes

  1import SwiftUI
  2
  3struct ContentView: View {
  4
  5    @Environment(SessionStore.self) private var session
  6    @Environment(PushRouter.self) private var router
  7    @Environment(\.openURL) private var openURL
  8    @Environment(\.horizontalSizeClass) private var sizeClass
  9
 10    enum Tabs { case dashboard, feed, repositories, explore, profile }
 11
 12    @State private var selectedTab = Tabs.dashboard
 13    @State private var dashboardPath = NavigationPath()
 14    @State private var repoPath = NavigationPath()
 15    @State private var feedPath = NavigationPath()
 16    @State private var explorePath = NavigationPath()
 17    @State private var profilePath = NavigationPath()
 18    @State private var unread = 0
 19
 20    /// whoami's admin flag, read on the My Profile tab, decides whether
 21    /// the account menu offers Admin.
 22    @State private var isAdmin = false
 23
 24    /// Which account `dashboardPath`'s routes were resolved against.
 25    ///
 26    /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so
 27    /// this view's own state outlives an account switch while the stack
 28    /// it feeds is rebuilt. A path carried across the switch would be
 29    /// re-pushed against the new account's client — someone else's
 30    /// issue number, usually a 404. Empty while signed out.
 31    @State private var pathAccount = ""
 32
 33    var body: some View {
 34        if let client = session.client, let account = session.current {
 35            TabView(selection: $selectedTab) {
 36                Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
 37                    if sizeClass == .regular {
 38                        SplitStack(path: $dashboardPath, client: client, placeholder: "Nothing selected") {
 39                            DashboardView(client: client, unread: $unread)
 40                        }
 41                    } else {
 42                        NavigationStack(path: $dashboardPath) {
 43                            DashboardView(client: client, unread: $unread)
 44                                .navigationDestinations(client: client)
 45                        }
 46                        .pushesRoutes(into: $dashboardPath)
 47                    }
 48                }
 49                .badge(unread)
 50                Tab("Feed", systemImage: "bolt", value: Tabs.feed) {
 51                    NavigationStack(path: $feedPath) {
 52                        FeedView(client: client)
 53                            .navigationDestinations(client: client)
 54                    }
 55                    .pushesRoutes(into: $feedPath)
 56                }
 57                Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
 58                    if sizeClass == .regular {
 59                        SplitStack(path: $repoPath, client: client, placeholder: "No repository selected") {
 60                            RepoListView(client: client)
 61                        }
 62                    } else {
 63                        NavigationStack(path: $repoPath) {
 64                            RepoListView(client: client)
 65                                .navigationDestinations(client: client)
 66                        }
 67                        .pushesRoutes(into: $repoPath)
 68                    }
 69                }
 70                Tab("Explore", systemImage: "safari", value: Tabs.explore) {
 71                    NavigationStack(path: $explorePath) {
 72                        ExploreView(client: client)
 73                            .navigationDestinations(client: client)
 74                    }
 75                    .pushesRoutes(into: $explorePath)
 76                }
 77                Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) {
 78                    NavigationStack(path: $profilePath) {
 79                        ProfileView(client: client, name: account.username,
 80                                    title: "My Profile")
 81                            .toolbar { AccountMenu(isAdmin: isAdmin) }
 82                            .task {
 83                                isAdmin = false
 84                                isAdmin = await AdminCheck.isAdmin(client)
 85                            }
 86                            .navigationDestinations(client: client)
 87                    }
 88                    .pushesRoutes(into: $profilePath)
 89                }
 90            }
 91            .id(account.id) // a fresh tab bar and stacks per account
 92            .task(id: DrainKey(account: account.id, target: router.pending, link: router.pendingLink)) {
 93                await routePendingNotification(account)
 94            }
 95        } else {
 96            NavigationStack {
 97                SignInView()
 98            }
 99            // Signing out leaves the stale path behind in this view's
100            // state; disowning it makes the next sign-in clear it.
101            .task { pathAccount = "" }
102        }
103    }
104
105    /// What makes the drain run again.
106    ///
107    /// The account is in here because the cross-account pass leaves
108    /// `pending` set on purpose, so the target alone does not change
109    /// across the switch it performs. Keying on both means the drain
110    /// does not depend on whether `.id()` propagates its identity
111    /// outward through the `.task` modifier attached after it.
112    nonisolated private struct DrainKey: Equatable, Sendable {
113        let account: String
114        let target: PushTarget?
115        let link: URL?
116    }
117
118    /// Resets the navigation state an account switch invalidated, then
119    /// routes a tapped notification, switching accounts first when it
120    /// belongs to another one. With no notification pending, a pending
121    /// universal link is routed instead.
122    ///
123    /// A switch changes `account.id`, which rebuilds the tab bar. That
124    /// is why the target is left pending across it: this runs again
125    /// against the new account and finds it already active, so the
126    /// second pass does the navigating. The drain is the mechanism, not
127    /// a workaround for it — and it is also where the path is reset,
128    /// because every switch comes through here whether a notification
129    /// is pending or not.
130    private func routePendingNotification(_ account: Account) async {
131        if pathAccount != account.id {
132            dashboardPath = NavigationPath()
133            repoPath = NavigationPath()
134            feedPath = NavigationPath()
135            explorePath = NavigationPath()
136            profilePath = NavigationPath()
137            selectedTab = .dashboard
138            pathAccount = account.id
139        }
140        guard let target = router.pending else {
141            routePendingLink(account)
142            return
143        }
144        if account.id != target.accountID {
145            guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
146                router.pending = nil   // signed out of it since the tap
147                return
148            }
149            // A token gone from the Keychain drops the account and
150            // leaves `current` alone, so nothing would rebuild and
151            // drain; the target would outlive every later tap, which
152            // cannot change the key either.
153            guard session.activate(other) else {
154                router.pending = nil
155                return
156            }
157            return                      // the rebuilt tree drains it
158        }
159        selectedTab = .dashboard
160        switch target.destination {
161        case .issue(let repo, let number):
162            dashboardPath.append(IssueRoute.issue(repo: repo, number: number))
163        case .mr(let repo, let number):
164            dashboardPath.append(MRRoute.mr(repo: repo, number: number))
165        case .build(let repo, let number):
166            dashboardPath.append(BuildRoute.detail(repo: repo, number: number))
167        }
168        router.pending = nil
169    }
170
171    /// Opens a pending universal link on the Dashboard stack, under the
172    /// current account when it is on the link's instance, otherwise under
173    /// one that is. Switching leaves the link pending for the rebuilt
174    /// tree, as a notification does. With no account there, or a token
175    /// gone, the link goes to Safari.
176    private func routePendingLink(_ account: Account) {
177        guard let url = router.pendingLink else { return }
178        guard let route = WebURLRoute(url: url),
179              let owner = WebURLRoute.account(for: url, current: account, accounts: session.accounts)
180        else {
181            router.pendingLink = nil
182            openURL(url)
183            return
184        }
185        if owner.id != account.id {
186            if !session.activate(owner) {
187                router.pendingLink = nil
188                openURL(url)
189            }
190            return
191        }
192        selectedTab = .dashboard
193        switch route {
194        case .repo(let r): dashboardPath.append(r)
195        case .issue(let r): dashboardPath.append(r)
196        case .mr(let r): dashboardPath.append(r)
197        case .build(let r): dashboardPath.append(r)
198        case .release(let r): dashboardPath.append(r)
199        case .org(let r): dashboardPath.append(r)
200        case .snippet(let r): dashboardPath.append(r)
201        }
202        router.pendingLink = nil
203    }
204
205}
206
207/// A list as a sidebar at regular width. What it selects is the root of
208/// the detail column's stack; the stack's path is the one the compact
209/// layout's stack uses, so notification and link routing, which append
210/// to it, work at either width.
211private struct SplitStack<Sidebar: View>: View {
212
213    @Binding var path: NavigationPath
214    let client: GitbayClient
215    let placeholder: String
216    @ViewBuilder let sidebar: () -> Sidebar
217
218    @State private var selection: AnyHashable?
219
220    var body: some View {
221        NavigationSplitView {
222            sidebar()
223                .environment(\.sidebarSelection, Binding(
224                    get: { selection },
225                    set: { route in
226                        selection = route
227                        path = NavigationPath()
228                    }
229                ))
230        } detail: {
231            NavigationStack(path: $path) {
232                Group {
233                    if let selection {
234                        RouteDestinations(client: client).destination(selection)
235                    } else {
236                        Text(placeholder)
237                            .font(.gbSans(.subheadline))
238                            .foregroundStyle(.secondary)
239                    }
240                }
241                .navigationDestinations(client: client)
242            }
243            .pushesRoutes(into: $path)
244        }
245    }
246}
247
248/// Every value-routed screen, attachable to any stack root.
249private struct RouteDestinations: ViewModifier {
250
251    let client: GitbayClient
252
253    func body(content: Content) -> some View {
254        content
255            .navigationDestination(for: RepoRoute.self) { route in
256                destination(route, client: client)
257            }
258            .navigationDestination(for: MRRoute.self) { route in
259                destination(route, client: client)
260            }
261            .navigationDestination(for: IssueRoute.self) { route in
262                destination(route, client: client)
263            }
264            .navigationDestination(for: BuildRoute.self) { route in
265                destination(route, client: client)
266            }
267            .navigationDestination(for: ReleaseRoute.self) { route in
268                destination(route, client: client)
269            }
270            .navigationDestination(for: OrgRoute.self) { route in
271                destination(route, client: client)
272            }
273            .navigationDestination(for: SnippetRoute.self) { route in
274                destination(route, client: client)
275            }
276    }
277
278    /// A type-erased route's screen, for a sidebar selection.
279    @ViewBuilder
280    func destination(_ route: AnyHashable) -> some View {
281        switch route.base {
282        case let r as RepoRoute: destination(r, client: client)
283        case let r as MRRoute: destination(r, client: client)
284        case let r as IssueRoute: destination(r, client: client)
285        case let r as BuildRoute: destination(r, client: client)
286        case let r as ReleaseRoute: destination(r, client: client)
287        case let r as OrgRoute: destination(r, client: client)
288        case let r as SnippetRoute: destination(r, client: client)
289        default: EmptyView()
290        }
291    }
292
293    @ViewBuilder
294    private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
295        switch route {
296        case .repo(let path):
297            RepoView(client: client, path: path)
298        case .tree(let repo, let directory, let ref):
299            TreeView(client: client, repo: repo, directory: directory, ref: ref)
300        case .file(let repo, let path, let ref):
301            FileView(client: client, repo: repo, path: path, ref: ref)
302        case .log(let repo, let ref, let path):
303            LogView(client: client, repo: repo, ref: ref, path: path)
304        case .settings(let repo):
305            RepoSettingsView(client: client, repo: repo)
306        case .grep(let repo):
307            GrepView(client: client, repo: repo)
308        case .blame(let repo, let path, let ref):
309            BlameView(client: client, repo: repo, path: path, ref: ref)
310        case .refs(let repo):
311            RefsView(client: client, repo: repo)
312        case .compare(let repo, let defaultBranch):
313            CompareView(client: client, repo: repo, defaultBranch: defaultBranch)
314        case .milestones(let repo):
315            MilestoneListView(client: client, repo: repo)
316        case .labels(let repo):
317            LabelListView(client: client, repo: repo)
318        case .bookmarks:
319            BookmarkListView(client: client)
320        case .webhooks(let repo):
321            WebhookListView(client: client, repo: repo)
322        case .webhookDeliveries(let repo):
323            WebhookDeliveriesView(client: client, repo: repo)
324        case .wiki(let repo):
325            WikiView(client: client, repo: repo)
326        case .wikiPage(let repo, let page):
327            WikiPageView(client: client, repo: repo, page: page)
328        case .commit(let repo, let sha):
329            CommitView(client: client, repo: repo, sha: sha)
330        case .profile(let name):
331            ProfileView(client: client, name: name)
332        case .account:
333            AccountView(client: client)
334        case .addAccount:
335            SignInView()
336        case .notifications:
337            NotificationsView(client: client)
338        case .admin:
339            AdminView(client: client)
340        case .adminUsers:
341            AdminUsersView(client: client)
342        }
343    }
344
345    @ViewBuilder
346    private func destination(_ route: MRRoute, client: GitbayClient) -> some View {
347        switch route {
348        case .list(let repo):
349            MRListView(client: client, repo: repo)
350        case .mr(let repo, let number):
351            MRView(client: client, repo: repo, number: number)
352        case .diff(let repo, let number):
353            DiffView(client: client, repo: repo, number: number)
354        case .rangeDiff(let repo, let number, let from, let to):
355            RangeDiffView(client: client, repo: repo, number: number, from: from, to: to)
356        }
357    }
358
359    @ViewBuilder
360    private func destination(_ route: IssueRoute, client: GitbayClient) -> some View {
361        switch route {
362        case .list(let repo):
363            IssueListView(client: client, repo: repo)
364        case .issue(let repo, let number):
365            IssueView(client: client, repo: repo, number: number)
366        }
367    }
368
369    @ViewBuilder
370    private func destination(_ route: OrgRoute, client: GitbayClient) -> some View {
371        switch route {
372        case .org(let name):
373            OrgView(client: client, org: name)
374        case .team(let org, let team):
375            TeamView(client: client, org: org, team: team)
376        case .labels(let org):
377            LabelListView(client: client, owner: .org(org))
378        case .milestones(let org):
379            MilestoneListView(client: client, owner: .org(org))
380        }
381    }
382
383    @ViewBuilder
384    private func destination(_ route: SnippetRoute, client: GitbayClient) -> some View {
385        switch route {
386        case .list(let owner):
387            SnippetListView(client: client, owner: owner)
388        case .snippet(let id):
389            SnippetView(client: client, id: id)
390        }
391    }
392
393    @ViewBuilder
394    private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View {
395        switch route {
396        case .list(let repo):
397            ReleaseListView(client: client, repo: repo)
398        case .release(let repo, let tag):
399            ReleaseView(client: client, repo: repo, tag: tag)
400        }
401    }
402
403    @ViewBuilder
404    private func destination(_ route: BuildRoute, client: GitbayClient) -> some View {
405        switch route {
406        case .list(let repo):
407            BuildListView(client: client, repo: repo)
408        case .detail(let repo, let number):
409            BuildDetailView(client: client, repo: repo, number: number)
410        }
411    }
412}
413
414extension View {
415    func navigationDestinations(client: GitbayClient) -> some View {
416        modifier(RouteDestinations(client: client))
417    }
418}
419
420#Preview {
421    // Every environment value the signed-in branch reads, so the
422    // preview does not depend on the machine's Keychain being empty.
423    let session = SessionStore()
424    ContentView()
425        .environment(session)
426        .environment(PushRouter())
427        .environment(PushRegistrar(session: session))
428}