krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.8.0: Hutch/App/RootView.swift · raw

  1import SwiftUI
  2import os
  3
  4private let rootDeepLinkLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
  5
  6/// The root view of the app. Shows a TabView when authenticated, or a
  7/// full-screen sheet for token entry on first launch.
  8struct RootView: View {
  9    @Environment(AppState.self) private var appState
 10    @Environment(\.isAMOLEDTheme) private var isAMOLED
 11    @State private var homePath = NavigationPath()
 12    @State private var morePath = NavigationPath()
 13    @State private var repoPath = NavigationPath()
 14    @State private var buildsPath = NavigationPath()
 15    @State private var ticketsPath = NavigationPath()
 16    @State private var isResolvingDeepLink = false
 17    @State private var hasValidatedLaunch = false
 18
 19    var body: some View {
 20        @Bindable var appState = appState
 21
 22        Group {
 23            switch appState.authPhase {
 24            case .launching:
 25                ProgressView(appState.authStatusMessage)
 26                    .task {
 27                        guard !hasValidatedLaunch else { return }
 28                        hasValidatedLaunch = true
 29                        await appState.validateOnLaunch()
 30                    }
 31
 32            case .unauthenticated:
 33                // Full-screen token entry that cannot be dismissed.
 34                TokenEntryView()
 35
 36            case .authenticated:
 37                tabContent
 38            }
 39        }
 40        .onChange(of: appState.pendingDeepLink) { _, newValue in
 41            consumePendingDeepLinkIfPossible(newValue)
 42        }
 43        .onChange(of: appState.authPhase) { _, newPhase in
 44            handleAuthPhaseChange(newPhase)
 45        }
 46        .onChange(of: appState.pendingTabNavigation) { _, newValue in
 47            consumePendingTabNavigationIfPossible(newValue)
 48        }
 49        .alert(
 50            "Couldn't Open Link",
 51            isPresented: Binding(
 52                get: { appState.deepLinkError != nil },
 53                set: { isPresented in
 54                    if !isPresented {
 55                        appState.deepLinkError = nil
 56                    }
 57                }
 58            )
 59        ) {
 60            Button("OK") {
 61                appState.deepLinkError = nil
 62            }
 63        } message: {
 64            Text(appState.deepLinkError ?? "")
 65        }
 66    }
 67
 68    // MARK: - Tab View
 69
 70    private var tabContent: some View {
 71        @Bindable var appState = appState
 72
 73        return TabView(selection: $appState.selectedTab) {
 74            NavigationStack(path: $homePath) {
 75                HomeView()
 76                    .navigationDestination(for: HomeRoute.self) { route in
 77                        switch route {
 78                        case .work(let scope):
 79                            WorkView(initialScope: scope)
 80                        }
 81                    }
 82            }
 83            .tag(AppState.Tab.home)
 84            .tabItem {
 85                Label("Home", systemImage: "house")
 86            }
 87
 88            NavigationStack(path: $repoPath) {
 89                RepositoryListView()
 90            }
 91            .tag(AppState.Tab.repositories)
 92            .tabItem {
 93                Label("Repositories", systemImage: "book.closed")
 94            }
 95
 96            NavigationStack(path: $ticketsPath) {
 97                TrackerListView()
 98                    // Deep link destination for jumping straight to a ticket.
 99                    .navigationDestination(for: TicketDeepLinkTarget.self) { target in
100                        TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
101                    }
102            }
103            .tag(AppState.Tab.tickets)
104            .tabItem {
105                Label("Trackers", systemImage: "checklist")
106            }
107
108            NavigationStack(path: $buildsPath) {
109                BuildListView()
110                    // Int destination used by deep links (hutch://builds/<id>).
111                    // JobSummary destination is registered inside BuildListView.
112                    .navigationDestination(for: Int.self) { jobId in
113                        BuildDetailView(jobId: jobId)
114                    }
115            }
116            .tag(AppState.Tab.builds)
117            .tabItem {
118                Label("Builds", systemImage: "hammer")
119            }
120
121            NavigationStack(path: $morePath) {
122                MoreNavigationRoot()
123            }
124            .tag(AppState.Tab.more)
125            .tabItem {
126                Label("More", systemImage: "ellipsis.circle")
127            }
128        }
129        .id(appState.sessionIdentity)
130        .defaultAppStorage(appState.accountDefaults)
131        .modifier(SidebarAdaptableTabStyle())
132        .modifier(AMOLEDToolbarStyle(isAMOLED: isAMOLED))
133        .modifier(TabKeyboardShortcuts(selectedTab: Binding(
134            get: { appState.selectedTab },
135            set: { appState.selectedTab = $0 }
136        )))
137        .safeAreaInset(edge: .bottom) {
138            if let message = appState.copyConfirmationMessage {
139                CopyConfirmationBadge(message: message)
140                    .padding(.bottom, 4)
141                    .transition(.move(edge: .bottom).combined(with: .opacity))
142            }
143        }
144        .overlay {
145            if isResolvingDeepLink {
146                ZStack {
147                    Color.black.opacity(0.3)
148                        .ignoresSafeArea()
149                    ProgressView("Opening link…")
150                        .padding()
151                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
152                }
153            }
154        }
155    }
156
157    // MARK: - Deep Link Handling
158
159    private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
160        rootDeepLinkLogger.info("Auth phase changed: \(String(describing: newPhase), privacy: .public); pendingDeepLink=\(String(describing: appState.pendingDeepLink), privacy: .public)")
161        switch newPhase {
162        case .launching:
163            break
164        case .unauthenticated:
165            homePath = NavigationPath()
166            morePath = NavigationPath()
167            repoPath = NavigationPath()
168            buildsPath = NavigationPath()
169            ticketsPath = NavigationPath()
170            appState.selectedTab = .home
171            isResolvingDeepLink = false
172        case .authenticated:
173            consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
174        }
175    }
176
177    private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
178        rootDeepLinkLogger.info("Attempting to consume pending deep link. authenticated=\(appState.isAuthenticated, privacy: .public), link=\(String(describing: link), privacy: .public)")
179        guard appState.isAuthenticated, let link else {
180            rootDeepLinkLogger.info("Deferred deep link consumption.")
181            return
182        }
183        handleDeepLink(link)
184        appState.pendingDeepLink = nil
185    }
186
187    private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
188        guard appState.isAuthenticated, let target else { return }
189        handleTabNavigation(target)
190        appState.pendingTabNavigation = nil
191    }
192
193    private func handleDeepLink(_ link: DeepLink) {
194        rootDeepLinkLogger.info("Handling deep link: \(String(describing: link), privacy: .public)")
195        guard appState.isAuthenticated else {
196            rootDeepLinkLogger.info("Ignoring deep link while unauthenticated: \(String(describing: link), privacy: .public)")
197            return
198        }
199
200        switch link {
201        case .home:
202            homePath = NavigationPath()
203            appState.selectedTab = .home
204
205        case .recentActivity:
206            homePath = NavigationPath()
207            appState.selectedTab = .home
208
209        case .repository(let service, let owner, let repo):
210            resolveRepositoryLink(service: service, owner: owner, repo: repo)
211
212        case .tracker(let owner, let tracker):
213            resolveTrackerLink(owner: owner, tracker: tracker)
214
215        case .build(let jobId):
216            buildsPath = NavigationPath()
217            appState.selectedTab = .builds
218            Task {
219                await settleNavigationTransition()
220                buildsPath.append(jobId)
221            }
222
223        case .ticket(let owner, let tracker, let ticketId):
224            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
225
226        case .mailingList(let owner, let list):
227            resolveMailingListLink(owner: owner, list: list)
228
229        case .userProfile(let owner):
230            resolveUserProfileLink(owner: owner)
231
232        case .work:
233            navigateToWork(scope: .all)
234
235        case .workQueue(let scope):
236            navigateToWork(scope: scope)
237
238        case .projectDashboard(let id, let title):
239            morePath = NavigationPath()
240            appState.selectedTab = .more
241            Task {
242                await settleNavigationTransition()
243                morePath.append(MoreRoute.projects)
244                morePath.append(MoreRoute.projectDashboard(id: id, title: title))
245            }
246
247        case .failedBuilds:
248            buildsPath = NavigationPath()
249            appState.pendingBuildListFilter = .failed
250            appState.selectedTab = .builds
251
252        case .search(let query):
253            morePath = NavigationPath()
254            appState.selectedTab = .more
255            Task {
256                await settleNavigationTransition()
257                morePath.append(MoreRoute.lookup(query: query))
258            }
259
260        case .lookup:
261            morePath = NavigationPath()
262            appState.selectedTab = .more
263            Task {
264                await settleNavigationTransition()
265                morePath.append(MoreRoute.lookup(query: nil))
266            }
267
268        case .buildsTab:
269            buildsPath = NavigationPath()
270            appState.selectedTab = .builds
271
272        case .repositoriesTab:
273            repoPath = NavigationPath()
274            appState.selectedTab = .repositories
275
276        case .trackersTab:
277            ticketsPath = NavigationPath()
278            appState.selectedTab = .tickets
279
280        case .systemStatus:
281            appState.navigateToSystemStatus()
282        }
283    }
284
285    private func navigateToWork(scope: HutchWorkQueueScope) {
286        homePath = NavigationPath()
287        appState.selectedTab = .home
288        Task {
289            await settleNavigationTransition()
290            homePath.append(HomeRoute.work(scope: scope))
291        }
292    }
293
294    /// Replaces the target tab's path in one assignment.
295    ///
296    /// Resetting the path and appending to it afterwards races when the target tab
297    /// is already the one on screen: the reset starts an animated pop of the view
298    /// the user is standing on, and the appends land mid-animation, leaving a blank
299    /// screen. That is why opening a mailing list from a pinned project on Home
300    /// worked while the same tap under More  Projects did not  one changes tabs
301    /// and the other does not.
302    ///
303    /// Building the whole path first and assigning once gives SwiftUI a single
304    /// diff, with nothing to race.
305    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
306        switch target {
307        case .repository(let repository):
308            var path = NavigationPath()
309            path.append(repository)
310            repoPath = path
311            appState.selectedTab = .repositories
312
313        case .tracker(let tracker):
314            var path = NavigationPath()
315            path.append(tracker)
316            ticketsPath = path
317            appState.selectedTab = .tickets
318
319        case .mailingList(let mailingList):
320            // .lists first so back lands on Mailing Lists rather than dead-ending.
321            var path = NavigationPath()
322            path.append(MoreRoute.lists)
323            path.append(MoreRoute.mailingList(mailingList))
324            morePath = path
325            appState.selectedTab = .more
326
327        case .systemStatus:
328            var path = NavigationPath()
329            path.append(MoreRoute.systemStatus)
330            morePath = path
331            appState.selectedTab = .more
332
333        case .builds:
334            buildsPath = NavigationPath()
335            appState.selectedTab = .builds
336        }
337    }
338
339    private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
340        isResolvingDeepLink = true
341        Task {
342            defer { isResolvingDeepLink = false }
343            do {
344                let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
345                repoPath = NavigationPath()
346                appState.selectedTab = .repositories
347                await settleNavigationTransition()
348                repoPath.append(summary)
349            } catch {
350                appState.presentRepositoryDeepLinkError()
351            }
352        }
353    }
354
355    private func resolveTrackerLink(owner: String, tracker: String) {
356        isResolvingDeepLink = true
357        Task {
358            defer { isResolvingDeepLink = false }
359            do {
360                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
361                ticketsPath = NavigationPath()
362                appState.selectedTab = .tickets
363                await settleNavigationTransition()
364                ticketsPath.append(trackerSummary)
365            } catch {
366                appState.presentTicketDeepLinkError()
367            }
368        }
369    }
370
371    private func resolveMailingListLink(owner: String, list: String) {
372        isResolvingDeepLink = true
373        Task {
374            defer { isResolvingDeepLink = false }
375            do {
376                let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
377                morePath = NavigationPath()
378                appState.selectedTab = .more
379                await settleNavigationTransition()
380                morePath.append(MoreRoute.lists)
381                morePath.append(MoreRoute.mailingList(mailingList))
382            } catch {
383                appState.deepLinkError = "The mailing list could not be found or is inaccessible."
384            }
385        }
386    }
387
388    private func resolveUserProfileLink(owner: String) {
389        rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
390        morePath = NavigationPath()
391        appState.selectedTab = .more
392        Task {
393            await settleNavigationTransition()
394            rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
395            morePath.append(MoreRoute.userProfile(owner))
396        }
397    }
398
399    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
400        isResolvingDeepLink = true
401        Task {
402            defer { isResolvingDeepLink = false }
403            do {
404                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
405                ticketsPath = NavigationPath()
406                appState.selectedTab = .tickets
407                await settleNavigationTransition()
408                ticketsPath.append(trackerSummary)
409                ticketsPath.append(TicketDeepLinkTarget(
410                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
411                    trackerName: trackerSummary.name,
412                    trackerId: trackerSummary.id,
413                    trackerRid: trackerSummary.rid,
414                    ticketId: ticketId
415                ))
416            } catch {
417                appState.presentTicketDeepLinkError()
418            }
419        }
420    }
421
422    @MainActor
423    private func settleNavigationTransition() async {
424        await Task.yield()
425        await Task.yield()
426    }
427}
428
429enum MoreDestination: Hashable {
430    case lists
431    case pastes
432    case settings
433}
434
435enum MoreRoute: Hashable {
436    case lookup(query: String?)
437    case projects
438    case lists
439    case pastes
440    case profile
441    case systemStatus
442    case settings
443    case about
444    case userProfile(String)
445    case projectDashboard(id: String, title: String?)
446    case mailingList(InboxMailingListReference)
447    case thread(InboxThreadSummary)
448    case manPageBrowser
449    case manPage(URL)
450}
451
452private struct MoreNavigationRoot: View {
453    @Environment(AppState.self) private var appState
454
455    var body: some View {
456        MoreView()
457            .navigationDestination(for: MoreRoute.self) { route in
458                switch route {
459                case .lookup(let query):
460                    LookupView(initialQuery: query ?? "")
461                case .projects:
462                    ProjectsListView()
463                case .lists:
464                    MailingListListView()
465                case .pastes:
466                    PasteListView()
467                case .profile:
468                    ProfileView()
469                case .systemStatus:
470                    SystemStatusView()
471                case .settings:
472                    SettingsView()
473                case .about:
474                    AboutView()
475                case .userProfile(let owner):
476                    UserProfileDeepLinkView(owner: owner)
477                case .projectDashboard(let id, let title):
478                    ProjectDashboardDeepLinkView(projectID: id, title: title)
479                case .mailingList(let mailingList):
480                    MailingListDetailView(mailingList: mailingList)
481                case .thread(let thread):
482                    ThreadDetailView(
483                        thread: thread,
484                        onViewed: {
485                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
486                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
487                        },
488                        onMarkRead: {
489                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
490                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
491                        },
492                        onMarkUnread: {
493                            InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
494                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
495                        }
496                    )
497                case .manPageBrowser:
498                    ManPageBrowserView()
499                case .manPage(let url):
500                    ManPageDetailView(url: url)
501                }
502            }
503    }
504}
505
506struct UserProfileDeepLinkView: View {
507    private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
508    @Environment(AppState.self) private var appState
509    let owner: String
510    @State private var user: User?
511    @State private var errorMessage: String?
512
513    var body: some View {
514        Group {
515            if let user {
516                UserProfileView(user: user)
517            } else if let errorMessage {
518                ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
519            } else {
520                SRHTLoadingStateView(message: "Loading profile...")
521            }
522        }
523        .navigationTitle(displayOwner)
524        .navigationBarTitleDisplayMode(.inline)
525        .task(id: owner) {
526            await loadProfile()
527        }
528    }
529
530    private var displayOwner: String {
531        owner.hasPrefix("~") ? owner : "~\(owner)"
532    }
533
534    @MainActor
535    private func loadProfile() async {
536        logger.info("Loading user profile for owner=\(owner, privacy: .public)")
537        errorMessage = nil
538        do {
539            user = try await appState.resolveUser(username: owner)
540            logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
541        } catch {
542            logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
543            errorMessage = "The user profile could not be found or is inaccessible."
544        }
545    }
546}
547
548struct ProjectDashboardDeepLinkView: View {
549    @Environment(AppState.self) private var appState
550    let projectID: String
551    let title: String?
552    @State private var project: Project?
553    @State private var errorMessage: String?
554
555    var body: some View {
556        Group {
557            if let project {
558                ProjectDetailView(project: project)
559            } else if let errorMessage {
560                ContentUnavailableView(
561                    "Couldn't Open Project",
562                    systemImage: "square.stack.3d.up.slash",
563                    description: Text(errorMessage)
564                )
565            } else {
566                SRHTLoadingStateView(message: "Loading project...")
567            }
568        }
569        .navigationTitle(title ?? "Project")
570        .navigationBarTitleDisplayMode(.inline)
571        .task(id: projectID) {
572            await loadProject()
573        }
574    }
575
576    @MainActor
577    private func loadProject() async {
578        errorMessage = nil
579        do {
580            project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID)
581        } catch {
582            errorMessage = "The project could not be found or is inaccessible."
583        }
584    }
585}
586
587// MARK: - Ticket Deep Link Navigation Target
588
589/// Hashable wrapper to push a ticket detail view from a deep link.
590struct TicketDeepLinkTarget: Hashable {
591    let ownerUsername: String
592    let trackerName: String
593    let trackerId: Int
594    let trackerRid: String
595    let ticketId: Int
596}
597
598// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
599
600/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
601private struct TabKeyboardShortcuts: ViewModifier {
602    @Binding var selectedTab: AppState.Tab
603
604    private static let tabMap: [String: AppState.Tab] = [
605        "1": .home,
606        "2": .repositories,
607        "3": .tickets,
608        "4": .builds,
609        "5": .more,
610    ]
611
612    func body(content: Content) -> some View {
613        content
614            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
615                guard press.modifiers == .command else { return .ignored }
616                let key = String(press.characters)
617                if let tab = Self.tabMap[key] {
618                    selectedTab = tab
619                    return .handled
620                }
621                return .ignored
622            }
623    }
624}
625
626// MARK: - AMOLED Toolbar Styling
627
628/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
629private struct AMOLEDToolbarStyle: ViewModifier {
630    let isAMOLED: Bool
631
632    func body(content: Content) -> some View {
633        if isAMOLED {
634            content
635                .toolbarBackground(Color.black, for: .tabBar)
636                .toolbarBackground(.visible, for: .tabBar)
637                .toolbarBackground(Color.black, for: .navigationBar)
638                .toolbarBackground(.visible, for: .navigationBar)
639        } else {
640            content
641        }
642    }
643}
644
645// MARK: - iPad Sidebar Adaptable
646
647/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
648/// becomes a full sidebar on iPad, while falling back to the standard tab
649/// bar on earlier releases.
650private struct SidebarAdaptableTabStyle: ViewModifier {
651    func body(content: Content) -> some View {
652        if #available(iOS 18.0, *) {
653            content.tabViewStyle(.sidebarAdaptable)
654        } else {
655            content
656        }
657    }
658}