krz/hutch

an ios client for sourcehut

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

v3.4.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:
 79                            WorkView()
 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 .repository(let service, let owner, let repo):
206            resolveRepositoryLink(service: service, owner: owner, repo: repo)
207
208        case .build(let jobId):
209            buildsPath = NavigationPath()
210            appState.selectedTab = .builds
211            Task {
212                await settleNavigationTransition()
213                buildsPath.append(jobId)
214            }
215
216        case .ticket(let owner, let tracker, let ticketId):
217            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
218
219        case .mailingList(let owner, let list):
220            resolveMailingListLink(owner: owner, list: list)
221
222        case .userProfile(let owner):
223            resolveUserProfileLink(owner: owner)
224
225        case .work:
226            homePath = NavigationPath()
227            appState.selectedTab = .home
228            Task {
229                await settleNavigationTransition()
230                homePath.append(HomeRoute.work)
231            }
232
233        case .buildsTab:
234            buildsPath = NavigationPath()
235            appState.selectedTab = .builds
236
237        case .repositoriesTab:
238            repoPath = NavigationPath()
239            appState.selectedTab = .repositories
240
241        case .trackersTab:
242            ticketsPath = NavigationPath()
243            appState.selectedTab = .tickets
244
245        case .systemStatus:
246            appState.navigateToSystemStatus()
247
248        case .lookup:
249            morePath = NavigationPath()
250            appState.selectedTab = .more
251            Task {
252                await settleNavigationTransition()
253                morePath.append(MoreRoute.lookup)
254            }
255        }
256    }
257
258    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
259        switch target {
260        case .repository(let repository):
261            repoPath = NavigationPath()
262            appState.selectedTab = .repositories
263            Task {
264                await settleNavigationTransition()
265                repoPath.append(repository)
266            }
267
268        case .tracker(let tracker):
269            ticketsPath = NavigationPath()
270            appState.selectedTab = .tickets
271            Task {
272                await settleNavigationTransition()
273                ticketsPath.append(tracker)
274            }
275
276        case .mailingList(let mailingList):
277            morePath = NavigationPath()
278            appState.selectedTab = .more
279            Task {
280                await settleNavigationTransition()
281                morePath.append(MoreRoute.lists)
282                morePath.append(MoreRoute.mailingList(mailingList))
283            }
284        case .systemStatus:
285            morePath = NavigationPath()
286            appState.selectedTab = .more
287            Task {
288                await settleNavigationTransition()
289                morePath.append(MoreRoute.systemStatus)
290            }
291        case .builds:
292            buildsPath = NavigationPath()
293            appState.selectedTab = .builds
294        }
295    }
296
297    private func resolveRepositoryLink(service: SRHTService, owner: String, repo: String) {
298        isResolvingDeepLink = true
299        Task {
300            defer { isResolvingDeepLink = false }
301            do {
302                let summary = try await appState.resolveRepository(owner: owner, name: repo, service: service)
303                repoPath = NavigationPath()
304                appState.selectedTab = .repositories
305                await settleNavigationTransition()
306                repoPath.append(summary)
307            } catch {
308                appState.presentRepositoryDeepLinkError()
309            }
310        }
311    }
312
313    private func resolveMailingListLink(owner: String, list: String) {
314        isResolvingDeepLink = true
315        Task {
316            defer { isResolvingDeepLink = false }
317            do {
318                let mailingList = try await appState.resolveMailingList(owner: owner, name: list)
319                morePath = NavigationPath()
320                appState.selectedTab = .more
321                await settleNavigationTransition()
322                morePath.append(MoreRoute.lists)
323                morePath.append(MoreRoute.mailingList(mailingList))
324            } catch {
325                appState.deepLinkError = "The mailing list could not be found or is inaccessible."
326            }
327        }
328    }
329
330    private func resolveUserProfileLink(owner: String) {
331        rootDeepLinkLogger.info("Routing user profile deep link for owner=\(owner, privacy: .public)")
332        morePath = NavigationPath()
333        appState.selectedTab = .more
334        Task {
335            await settleNavigationTransition()
336            rootDeepLinkLogger.info("Appending user profile route for owner=\(owner, privacy: .public)")
337            morePath.append(MoreRoute.userProfile(owner))
338        }
339    }
340
341    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
342        isResolvingDeepLink = true
343        Task {
344            defer { isResolvingDeepLink = false }
345            do {
346                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
347                ticketsPath = NavigationPath()
348                appState.selectedTab = .tickets
349                await settleNavigationTransition()
350                ticketsPath.append(trackerSummary)
351                ticketsPath.append(TicketDeepLinkTarget(
352                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
353                    trackerName: trackerSummary.name,
354                    trackerId: trackerSummary.id,
355                    trackerRid: trackerSummary.rid,
356                    ticketId: ticketId
357                ))
358            } catch {
359                appState.presentTicketDeepLinkError()
360            }
361        }
362    }
363
364    @MainActor
365    private func settleNavigationTransition() async {
366        await Task.yield()
367        await Task.yield()
368    }
369}
370
371enum MoreDestination: Hashable {
372    case lists
373    case pastes
374    case settings
375}
376
377enum MoreRoute: Hashable {
378    case lookup
379    case projects
380    case lists
381    case pastes
382    case profile
383    case systemStatus
384    case settings
385    case about
386    case userProfile(String)
387    case mailingList(InboxMailingListReference)
388    case thread(InboxThreadSummary)
389    case manPageBrowser
390    case manPage(URL)
391}
392
393private struct MoreNavigationRoot: View {
394    @Environment(AppState.self) private var appState
395
396    var body: some View {
397        MoreView()
398            .navigationDestination(for: MoreRoute.self) { route in
399                switch route {
400                case .lookup:
401                    LookupView()
402                case .projects:
403                    ProjectsListView()
404                case .lists:
405                    MailingListListView()
406                case .pastes:
407                    PasteListView()
408                case .profile:
409                    ProfileView()
410                case .systemStatus:
411                    SystemStatusView()
412                case .settings:
413                    SettingsView()
414                case .about:
415                    AboutView()
416                case .userProfile(let owner):
417                    UserProfileDeepLinkView(owner: owner)
418                case .mailingList(let mailingList):
419                    MailingListDetailView(mailingList: mailingList)
420                case .thread(let thread):
421                    ThreadDetailView(
422                        thread: thread,
423                        onViewed: {
424                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
425                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
426                        },
427                        onMarkRead: {
428                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
429                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
430                        },
431                        onMarkUnread: {
432                            InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
433                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
434                        }
435                    )
436                case .manPageBrowser:
437                    ManPageBrowserView()
438                case .manPage(let url):
439                    ManPageDetailView(url: url)
440                }
441            }
442    }
443}
444
445struct UserProfileDeepLinkView: View {
446    private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
447    @Environment(AppState.self) private var appState
448    let owner: String
449    @State private var user: User?
450    @State private var errorMessage: String?
451
452    var body: some View {
453        Group {
454            if let user {
455                UserProfileView(user: user)
456            } else if let errorMessage {
457                ContentUnavailableView("Couldn't Open Profile", systemImage: "person.crop.circle.badge.exclamationmark", description: Text(errorMessage))
458            } else {
459                SRHTLoadingStateView(message: "Loading profile...")
460            }
461        }
462        .navigationTitle(displayOwner)
463        .navigationBarTitleDisplayMode(.inline)
464        .task(id: owner) {
465            await loadProfile()
466        }
467    }
468
469    private var displayOwner: String {
470        owner.hasPrefix("~") ? owner : "~\(owner)"
471    }
472
473    @MainActor
474    private func loadProfile() async {
475        logger.info("Loading user profile for owner=\(owner, privacy: .public)")
476        errorMessage = nil
477        do {
478            user = try await appState.resolveUser(username: owner)
479            logger.info("Loaded user profile for owner=\(owner, privacy: .public), canonical=\(user?.canonicalName ?? "nil", privacy: .public)")
480        } catch {
481            logger.error("Failed loading user profile for owner=\(owner, privacy: .public): \(String(describing: error), privacy: .public)")
482            errorMessage = "The user profile could not be found or is inaccessible."
483        }
484    }
485}
486
487// MARK: - Ticket Deep Link Navigation Target
488
489/// Hashable wrapper to push a ticket detail view from a deep link.
490struct TicketDeepLinkTarget: Hashable {
491    let ownerUsername: String
492    let trackerName: String
493    let trackerId: Int
494    let trackerRid: String
495    let ticketId: Int
496}
497
498// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
499
500/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
501private struct TabKeyboardShortcuts: ViewModifier {
502    @Binding var selectedTab: AppState.Tab
503
504    private static let tabMap: [String: AppState.Tab] = [
505        "1": .home,
506        "2": .repositories,
507        "3": .tickets,
508        "4": .builds,
509        "5": .more,
510    ]
511
512    func body(content: Content) -> some View {
513        content
514            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
515                guard press.modifiers == .command else { return .ignored }
516                let key = String(press.characters)
517                if let tab = Self.tabMap[key] {
518                    selectedTab = tab
519                    return .handled
520                }
521                return .ignored
522            }
523    }
524}
525
526// MARK: - AMOLED Toolbar Styling
527
528/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
529private struct AMOLEDToolbarStyle: ViewModifier {
530    let isAMOLED: Bool
531
532    func body(content: Content) -> some View {
533        if isAMOLED {
534            content
535                .toolbarBackground(Color.black, for: .tabBar)
536                .toolbarBackground(.visible, for: .tabBar)
537                .toolbarBackground(Color.black, for: .navigationBar)
538                .toolbarBackground(.visible, for: .navigationBar)
539        } else {
540            content
541        }
542    }
543}
544
545// MARK: - iPad Sidebar Adaptable
546
547/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
548/// becomes a full sidebar on iPad, while falling back to the standard tab
549/// bar on earlier releases.
550private struct SidebarAdaptableTabStyle: ViewModifier {
551    func body(content: Content) -> some View {
552        if #available(iOS 18.0, *) {
553            content.tabViewStyle(.sidebarAdaptable)
554        } else {
555            content
556        }
557    }
558}