krz/hutch

an ios client for sourcehut

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

v3.8.1: 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        // Recent activity is a section of the Home tab, not a screen of its
202        // own, so its intent/widget deep link lands on Home like .home does.
203        case .home, .recentActivity:
204            homePath = NavigationPath()
205            appState.selectedTab = .home
206
207        case .repository(let service, let owner, let repo):
208            resolveRepositoryLink(service: service, owner: owner, repo: repo)
209
210        case .tracker(let owner, let tracker):
211            resolveTrackerLink(owner: owner, tracker: tracker)
212
213        case .build(let jobId):
214            buildsPath = NavigationPath()
215            appState.selectedTab = .builds
216            Task {
217                await settleNavigationTransition()
218                buildsPath.append(jobId)
219            }
220
221        case .ticket(let owner, let tracker, let ticketId):
222            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
223
224        case .mailingList(let owner, let list):
225            resolveMailingListLink(owner: owner, list: list)
226
227        case .userProfile(let owner):
228            resolveUserProfileLink(owner: owner)
229
230        case .work:
231            navigateToWork(scope: .all)
232
233        case .workQueue(let scope):
234            navigateToWork(scope: scope)
235
236        case .projectDashboard(let id, let title):
237            morePath = NavigationPath()
238            appState.selectedTab = .more
239            Task {
240                await settleNavigationTransition()
241                morePath.append(MoreRoute.projects)
242                morePath.append(MoreRoute.projectDashboard(id: id, title: title))
243            }
244
245        case .failedBuilds:
246            buildsPath = NavigationPath()
247            appState.pendingBuildListFilter = .failed
248            appState.selectedTab = .builds
249
250        case .search(let query):
251            morePath = NavigationPath()
252            appState.selectedTab = .more
253            Task {
254                await settleNavigationTransition()
255                morePath.append(MoreRoute.lookup(query: query))
256            }
257
258        case .lookup:
259            morePath = NavigationPath()
260            appState.selectedTab = .more
261            Task {
262                await settleNavigationTransition()
263                morePath.append(MoreRoute.lookup(query: nil))
264            }
265
266        case .buildsTab:
267            buildsPath = NavigationPath()
268            appState.selectedTab = .builds
269
270        case .repositoriesTab:
271            repoPath = NavigationPath()
272            appState.selectedTab = .repositories
273
274        case .trackersTab:
275            ticketsPath = NavigationPath()
276            appState.selectedTab = .tickets
277
278        case .systemStatus:
279            appState.navigateToSystemStatus()
280        }
281    }
282
283    private func navigateToWork(scope: HutchWorkQueueScope) {
284        homePath = NavigationPath()
285        appState.selectedTab = .home
286        Task {
287            await settleNavigationTransition()
288            homePath.append(HomeRoute.work(scope: scope))
289        }
290    }
291
292    /// Replaces the target tab's path in one assignment.
293    ///
294    /// Resetting the path and appending to it afterwards races when the target tab
295    /// is already the one on screen: the reset starts an animated pop of the view
296    /// the user is standing on, and the appends land mid-animation, leaving a blank
297    /// screen. That is why opening a mailing list from a pinned project on Home
298    /// worked while the same tap under More  Projects did not  one changes tabs
299    /// and the other does not.
300    ///
301    /// Building the whole path first and assigning once gives SwiftUI a single
302    /// diff, with nothing to race.
303    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
304        switch target {
305        case .repository(let repository):
306            var path = NavigationPath()
307            path.append(repository)
308            repoPath = path
309            appState.selectedTab = .repositories
310
311        case .tracker(let tracker):
312            var path = NavigationPath()
313            path.append(tracker)
314            ticketsPath = path
315            appState.selectedTab = .tickets
316
317        case .mailingList(let mailingList):
318            // .lists first so back lands on Mailing Lists rather than dead-ending.
319            var path = NavigationPath()
320            path.append(MoreRoute.lists)
321            path.append(MoreRoute.mailingList(mailingList))
322            morePath = path
323            appState.selectedTab = .more
324
325        case .systemStatus:
326            var path = NavigationPath()
327            path.append(MoreRoute.systemStatus)
328            morePath = path
329            appState.selectedTab = .more
330
331        case .builds:
332            buildsPath = NavigationPath()
333            appState.selectedTab = .builds
334        }
335    }
336
337    private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
338        isResolvingDeepLink = true
339        Task {
340            defer { isResolvingDeepLink = false }
341            do {
342                let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
343                repoPath = NavigationPath()
344                appState.selectedTab = .repositories
345                await settleNavigationTransition()
346                repoPath.append(summary)
347            } catch {
348                appState.presentRepositoryDeepLinkError()
349            }
350        }
351    }
352
353    private func resolveTrackerLink(owner: String, tracker: String) {
354        isResolvingDeepLink = true
355        Task {
356            defer { isResolvingDeepLink = false }
357            do {
358                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
359                ticketsPath = NavigationPath()
360                appState.selectedTab = .tickets
361                await settleNavigationTransition()
362                ticketsPath.append(trackerSummary)
363            } catch {
364                appState.presentTicketDeepLinkError()
365            }
366        }
367    }
368
369    private func resolveMailingListLink(owner: String, list: String) {
370        isResolvingDeepLink = true
371        Task {
372            defer { isResolvingDeepLink = false }
373            do {
374                let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
375                morePath = NavigationPath()
376                appState.selectedTab = .more
377                await settleNavigationTransition()
378                morePath.append(MoreRoute.lists)
379                morePath.append(MoreRoute.mailingList(mailingList))
380            } catch {
381                appState.deepLinkError = "The mailing list could not be found or is inaccessible."
382            }
383        }
384    }
385
386    private func resolveUserProfileLink(owner: String) {
387        rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
388        morePath = NavigationPath()
389        appState.selectedTab = .more
390        Task {
391            await settleNavigationTransition()
392            rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
393            morePath.append(MoreRoute.userProfile(owner))
394        }
395    }
396
397    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
398        isResolvingDeepLink = true
399        Task {
400            defer { isResolvingDeepLink = false }
401            do {
402                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
403                ticketsPath = NavigationPath()
404                appState.selectedTab = .tickets
405                await settleNavigationTransition()
406                ticketsPath.append(trackerSummary)
407                ticketsPath.append(TicketDeepLinkTarget(
408                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
409                    trackerName: trackerSummary.name,
410                    trackerId: trackerSummary.id,
411                    trackerRid: trackerSummary.rid,
412                    ticketId: ticketId
413                ))
414            } catch {
415                appState.presentTicketDeepLinkError()
416            }
417        }
418    }
419
420    @MainActor
421    private func settleNavigationTransition() async {
422        await Task.yield()
423        await Task.yield()
424    }
425}
426
427enum MoreDestination: Hashable {
428    case lists
429    case pastes
430    case settings
431}
432
433enum MoreRoute: Hashable {
434    case lookup(query: String?)
435    case projects
436    case lists
437    case pastes
438    case profile
439    case systemStatus
440    case settings
441    case about
442    case userProfile(String)
443    case projectDashboard(id: String, title: String?)
444    case mailingList(InboxMailingListReference)
445    case thread(InboxThreadSummary)
446    case manPageBrowser
447    case manPage(URL)
448}
449
450private struct MoreNavigationRoot: View {
451    @Environment(AppState.self) private var appState
452
453    var body: some View {
454        MoreView()
455            .navigationDestination(for: MoreRoute.self) { route in
456                switch route {
457                case .lookup(let query):
458                    LookupView(initialQuery: query ?? "")
459                case .projects:
460                    ProjectsListView()
461                case .lists:
462                    MailingListListView()
463                case .pastes:
464                    PasteListView()
465                case .profile:
466                    ProfileView()
467                case .systemStatus:
468                    SystemStatusView()
469                case .settings:
470                    SettingsView()
471                case .about:
472                    AboutView()
473                case .userProfile(let owner):
474                    UserProfileDeepLinkView(owner: owner)
475                case .projectDashboard(let id, let title):
476                    ProjectDashboardDeepLinkView(projectID: id, title: title)
477                case .mailingList(let mailingList):
478                    MailingListDetailView(mailingList: mailingList)
479                case .thread(let thread):
480                    ThreadDetailView(
481                        thread: thread,
482                        onViewed: {
483                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
484                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
485                        },
486                        onMarkRead: {
487                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
488                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
489                        },
490                        onMarkUnread: {
491                            InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
492                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
493                        }
494                    )
495                case .manPageBrowser:
496                    ManPageBrowserView()
497                case .manPage(let url):
498                    ManPageDetailView(url: url)
499                }
500            }
501    }
502}
503
504struct UserProfileDeepLinkView: View {
505    private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
506    @Environment(AppState.self) private var appState
507    let owner: String
508    @State private var user: User?
509    @State private var errorMessage: String?
510
511    var body: some View {
512        Group {
513            if let user {
514                UserProfileView(user: user)
515            } else if let errorMessage {
516                ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
517            } else {
518                SRHTLoadingStateView(message: "Loading profile...")
519            }
520        }
521        .navigationTitle(displayOwner)
522        .navigationBarTitleDisplayMode(.inline)
523        .task(id: owner) {
524            await loadProfile()
525        }
526    }
527
528    private var displayOwner: String {
529        owner.hasPrefix("~") ? owner : "~\(owner)"
530    }
531
532    @MainActor
533    private func loadProfile() async {
534        logger.info("Loading user profile for owner=\(owner, privacy: .public)")
535        errorMessage = nil
536        do {
537            user = try await appState.resolveUser(username: owner)
538            logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
539        } catch {
540            logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
541            errorMessage = "The user profile could not be found or is inaccessible."
542        }
543    }
544}
545
546struct ProjectDashboardDeepLinkView: View {
547    @Environment(AppState.self) private var appState
548    let projectID: String
549    let title: String?
550    @State private var project: Project?
551    @State private var errorMessage: String?
552
553    var body: some View {
554        Group {
555            if let project {
556                ProjectDetailView(project: project)
557            } else if let errorMessage {
558                ContentUnavailableView(
559                    "Couldn't Open Project",
560                    systemImage: "square.stack.3d.up.slash",
561                    description: Text(errorMessage)
562                )
563            } else {
564                SRHTLoadingStateView(message: "Loading project...")
565            }
566        }
567        .navigationTitle(title ?? "Project")
568        .navigationBarTitleDisplayMode(.inline)
569        .task(id: projectID) {
570            await loadProject()
571        }
572    }
573
574    @MainActor
575    private func loadProject() async {
576        errorMessage = nil
577        do {
578            project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID)
579        } catch {
580            errorMessage = "The project could not be found or is inaccessible."
581        }
582    }
583}
584
585// MARK: - Ticket Deep Link Navigation Target
586
587/// Hashable wrapper to push a ticket detail view from a deep link.
588struct TicketDeepLinkTarget: Hashable {
589    let ownerUsername: String
590    let trackerName: String
591    let trackerId: Int
592    let trackerRid: String
593    let ticketId: Int
594}
595
596// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
597
598/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
599private struct TabKeyboardShortcuts: ViewModifier {
600    @Binding var selectedTab: AppState.Tab
601
602    private static let tabMap: [String: AppState.Tab] = [
603        "1": .home,
604        "2": .repositories,
605        "3": .tickets,
606        "4": .builds,
607        "5": .more,
608    ]
609
610    func body(content: Content) -> some View {
611        content
612            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
613                guard press.modifiers == .command else { return .ignored }
614                let key = String(press.characters)
615                if let tab = Self.tabMap[key] {
616                    selectedTab = tab
617                    return .handled
618                }
619                return .ignored
620            }
621    }
622}
623
624// MARK: - AMOLED Toolbar Styling
625
626/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
627private struct AMOLEDToolbarStyle: ViewModifier {
628    let isAMOLED: Bool
629
630    func body(content: Content) -> some View {
631        if isAMOLED {
632            content
633                .toolbarBackground(Color.black, for: .tabBar)
634                .toolbarBackground(.visible, for: .tabBar)
635                .toolbarBackground(Color.black, for: .navigationBar)
636                .toolbarBackground(.visible, for: .navigationBar)
637        } else {
638            content
639        }
640    }
641}
642
643// MARK: - iPad Sidebar Adaptable
644
645/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
646/// becomes a full sidebar on iPad, while falling back to the standard tab
647/// bar on earlier releases.
648private struct SidebarAdaptableTabStyle: ViewModifier {
649    func body(content: Content) -> some View {
650        if #available(iOS 18.0, *) {
651            content.tabViewStyle(.sidebarAdaptable)
652        } else {
653            content
654        }
655    }
656}