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}