krz/hutch

an ios client for sourcehut

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

v3.5.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    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
295        switch target {
296        case .repository(let repository):
297            repoPath = NavigationPath()
298            appState.selectedTab = .repositories
299            Task {
300                await settleNavigationTransition()
301                repoPath.append(repository)
302            }
303
304        case .tracker(let tracker):
305            ticketsPath = NavigationPath()
306            appState.selectedTab = .tickets
307            Task {
308                await settleNavigationTransition()
309                ticketsPath.append(tracker)
310            }
311
312        case .mailingList(let mailingList):
313            morePath = NavigationPath()
314            appState.selectedTab = .more
315            Task {
316                await settleNavigationTransition()
317                morePath.append(MoreRoute.lists)
318                morePath.append(MoreRoute.mailingList(mailingList))
319            }
320        case .systemStatus:
321            morePath = NavigationPath()
322            appState.selectedTab = .more
323            Task {
324                await settleNavigationTransition()
325                morePath.append(MoreRoute.systemStatus)
326            }
327        case .builds:
328            buildsPath = NavigationPath()
329            appState.selectedTab = .builds
330        }
331    }
332
333    private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
334        isResolvingDeepLink = true
335        Task {
336            defer { isResolvingDeepLink = false }
337            do {
338                let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
339                repoPath = NavigationPath()
340                appState.selectedTab = .repositories
341                await settleNavigationTransition()
342                repoPath.append(summary)
343            } catch {
344                appState.presentRepositoryDeepLinkError()
345            }
346        }
347    }
348
349    private func resolveTrackerLink(owner: String, tracker: String) {
350        isResolvingDeepLink = true
351        Task {
352            defer { isResolvingDeepLink = false }
353            do {
354                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
355                ticketsPath = NavigationPath()
356                appState.selectedTab = .tickets
357                await settleNavigationTransition()
358                ticketsPath.append(trackerSummary)
359            } catch {
360                appState.presentTicketDeepLinkError()
361            }
362        }
363    }
364
365    private func resolveMailingListLink(owner: String, list: String) {
366        isResolvingDeepLink = true
367        Task {
368            defer { isResolvingDeepLink = false }
369            do {
370                let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
371                morePath = NavigationPath()
372                appState.selectedTab = .more
373                await settleNavigationTransition()
374                morePath.append(MoreRoute.lists)
375                morePath.append(MoreRoute.mailingList(mailingList))
376            } catch {
377                appState.deepLinkError = "The mailing list could not be found or is inaccessible."
378            }
379        }
380    }
381
382    private func resolveUserProfileLink(owner: String) {
383        rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
384        morePath = NavigationPath()
385        appState.selectedTab = .more
386        Task {
387            await settleNavigationTransition()
388            rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
389            morePath.append(MoreRoute.userProfile(owner))
390        }
391    }
392
393    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
394        isResolvingDeepLink = true
395        Task {
396            defer { isResolvingDeepLink = false }
397            do {
398                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
399                ticketsPath = NavigationPath()
400                appState.selectedTab = .tickets
401                await settleNavigationTransition()
402                ticketsPath.append(trackerSummary)
403                ticketsPath.append(TicketDeepLinkTarget(
404                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
405                    trackerName: trackerSummary.name,
406                    trackerId: trackerSummary.id,
407                    trackerRid: trackerSummary.rid,
408                    ticketId: ticketId
409                ))
410            } catch {
411                appState.presentTicketDeepLinkError()
412            }
413        }
414    }
415
416    @MainActor
417    private func settleNavigationTransition() async {
418        await Task.yield()
419        await Task.yield()
420    }
421}
422
423enum MoreDestination: Hashable {
424    case lists
425    case pastes
426    case settings
427}
428
429enum MoreRoute: Hashable {
430    case lookup(query: String?)
431    case projects
432    case lists
433    case pastes
434    case profile
435    case systemStatus
436    case settings
437    case about
438    case userProfile(String)
439    case projectDashboard(id: String, title: String?)
440    case mailingList(InboxMailingListReference)
441    case thread(InboxThreadSummary)
442    case manPageBrowser
443    case manPage(URL)
444}
445
446private struct MoreNavigationRoot: View {
447    @Environment(AppState.self) private var appState
448
449    var body: some View {
450        MoreView()
451            .navigationDestination(for: MoreRoute.self) { route in
452                switch route {
453                case .lookup(let query):
454                    LookupView(initialQuery: query ?? "")
455                case .projects:
456                    ProjectsListView()
457                case .lists:
458                    MailingListListView()
459                case .pastes:
460                    PasteListView()
461                case .profile:
462                    ProfileView()
463                case .systemStatus:
464                    SystemStatusView()
465                case .settings:
466                    SettingsView()
467                case .about:
468                    AboutView()
469                case .userProfile(let owner):
470                    UserProfileDeepLinkView(owner: owner)
471                case .projectDashboard(let id, let title):
472                    ProjectDashboardDeepLinkView(projectID: id, title: title)
473                case .mailingList(let mailingList):
474                    MailingListDetailView(mailingList: mailingList)
475                case .thread(let thread):
476                    ThreadDetailView(
477                        thread: thread,
478                        onViewed: {
479                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
480                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
481                        },
482                        onMarkRead: {
483                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
484                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
485                        },
486                        onMarkUnread: {
487                            InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
488                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
489                        }
490                    )
491                case .manPageBrowser:
492                    ManPageBrowserView()
493                case .manPage(let url):
494                    ManPageDetailView(url: url)
495                }
496            }
497    }
498}
499
500struct UserProfileDeepLinkView: View {
501    private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
502    @Environment(AppState.self) private var appState
503    let owner: String
504    @State private var user: User?
505    @State private var errorMessage: String?
506
507    var body: some View {
508        Group {
509            if let user {
510                UserProfileView(user: user)
511            } else if let errorMessage {
512                ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
513            } else {
514                SRHTLoadingStateView(message: "Loading profile...")
515            }
516        }
517        .navigationTitle(displayOwner)
518        .navigationBarTitleDisplayMode(.inline)
519        .task(id: owner) {
520            await loadProfile()
521        }
522    }
523
524    private var displayOwner: String {
525        owner.hasPrefix("~") ? owner : "~\(owner)"
526    }
527
528    @MainActor
529    private func loadProfile() async {
530        logger.info("Loading user profile for owner=\(owner, privacy: .public)")
531        errorMessage = nil
532        do {
533            user = try await appState.resolveUser(username: owner)
534            logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
535        } catch {
536            logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
537            errorMessage = "The user profile could not be found or is inaccessible."
538        }
539    }
540}
541
542struct ProjectDashboardDeepLinkView: View {
543    @Environment(AppState.self) private var appState
544    let projectID: String
545    let title: String?
546    @State private var project: Project?
547    @State private var errorMessage: String?
548
549    var body: some View {
550        Group {
551            if let project {
552                ProjectDetailView(project: project)
553            } else if let errorMessage {
554                ContentUnavailableView(
555                    "Couldn't Open Project",
556                    systemImage: "square.stack.3d.up.slash",
557                    description: Text(errorMessage)
558                )
559            } else {
560                SRHTLoadingStateView(message: "Loading project...")
561            }
562        }
563        .navigationTitle(title ?? "Project")
564        .navigationBarTitleDisplayMode(.inline)
565        .task(id: projectID) {
566            await loadProject()
567        }
568    }
569
570    @MainActor
571    private func loadProject() async {
572        errorMessage = nil
573        do {
574            project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID)
575        } catch {
576            errorMessage = "The project could not be found or is inaccessible."
577        }
578    }
579}
580
581// MARK: - Ticket Deep Link Navigation Target
582
583/// Hashable wrapper to push a ticket detail view from a deep link.
584struct TicketDeepLinkTarget: Hashable {
585    let ownerUsername: String
586    let trackerName: String
587    let trackerId: Int
588    let trackerRid: String
589    let ticketId: Int
590}
591
592// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
593
594/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
595private struct TabKeyboardShortcuts: ViewModifier {
596    @Binding var selectedTab: AppState.Tab
597
598    private static let tabMap: [String: AppState.Tab] = [
599        "1": .home,
600        "2": .repositories,
601        "3": .tickets,
602        "4": .builds,
603        "5": .more,
604    ]
605
606    func body(content: Content) -> some View {
607        content
608            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
609                guard press.modifiers == .command else { return .ignored }
610                let key = String(press.characters)
611                if let tab = Self.tabMap[key] {
612                    selectedTab = tab
613                    return .handled
614                }
615                return .ignored
616            }
617    }
618}
619
620// MARK: - AMOLED Toolbar Styling
621
622/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
623private struct AMOLEDToolbarStyle: ViewModifier {
624    let isAMOLED: Bool
625
626    func body(content: Content) -> some View {
627        if isAMOLED {
628            content
629                .toolbarBackground(Color.black, for: .tabBar)
630                .toolbarBackground(.visible, for: .tabBar)
631                .toolbarBackground(Color.black, for: .navigationBar)
632                .toolbarBackground(.visible, for: .navigationBar)
633        } else {
634            content
635        }
636    }
637}
638
639// MARK: - iPad Sidebar Adaptable
640
641/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
642/// becomes a full sidebar on iPad, while falling back to the standard tab
643/// bar on earlier releases.
644private struct SidebarAdaptableTabStyle: ViewModifier {
645    func body(content: Content) -> some View {
646        if #available(iOS 18.0, *) {
647            content.tabViewStyle(.sidebarAdaptable)
648        } else {
649            content
650        }
651    }
652}