Commit a9d2e38653
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +4 −4
| @@ -363,7 +363,7 @@ | |||
| 363 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 363 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 364 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 364 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 365 | CODE_SIGN_STYLE = Automatic; | 365 | CODE_SIGN_STYLE = Automatic; |
| 366 | CURRENT_PROJECT_VERSION = 2; | 366 | CURRENT_PROJECT_VERSION = 5; |
| 367 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 367 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 368 | ENABLE_PREVIEWS = YES; | 368 | ENABLE_PREVIEWS = YES; |
| 369 | GENERATE_INFOPLIST_FILE = YES; | 369 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -380,7 +380,7 @@ | |||
| 380 | "$(inherited)", | 380 | "$(inherited)", |
| 381 | "@executable_path/Frameworks", | 381 | "@executable_path/Frameworks", |
| 382 | ); | 382 | ); |
| 383 | MARKETING_VERSION = 1.2; | 383 | MARKETING_VERSION = 2.0; |
| 384 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 384 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 385 | PRODUCT_NAME = "$(TARGET_NAME)"; | 385 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 386 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 386 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -399,7 +399,7 @@ | |||
| 399 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 399 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 400 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 400 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 401 | CODE_SIGN_STYLE = Automatic; | 401 | CODE_SIGN_STYLE = Automatic; |
| 402 | CURRENT_PROJECT_VERSION = 2; | 402 | CURRENT_PROJECT_VERSION = 5; |
| 403 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 403 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 404 | ENABLE_PREVIEWS = YES; | 404 | ENABLE_PREVIEWS = YES; |
| 405 | GENERATE_INFOPLIST_FILE = YES; | 405 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -416,7 +416,7 @@ | |||
| 416 | "$(inherited)", | 416 | "$(inherited)", |
| 417 | "@executable_path/Frameworks", | 417 | "@executable_path/Frameworks", |
| 418 | ); | 418 | ); |
| 419 | MARKETING_VERSION = 1.2; | 419 | MARKETING_VERSION = 2.0; |
| 420 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 420 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 421 | PRODUCT_NAME = "$(TARGET_NAME)"; | 421 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 422 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 422 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
Hutch/App/AppState.swift +41 −5
| @@ -9,11 +9,16 @@ final class AppState { | |||
| 9 | 9 | ||
| 10 | enum Tab: Hashable { | 10 | enum Tab: Hashable { |
| 11 | case home | 11 | case home |
| 12 | case inbox | ||
| 13 | case repositories | 12 | case repositories |
| 14 | case builds | ||
| 15 | case tickets | 13 | case tickets |
| 16 | case settings | 14 | case builds |
| 15 | case more | ||
| 16 | } | ||
| 17 | |||
| 18 | enum TabNavigationTarget: Hashable { | ||
| 19 | case repository(RepositorySummary) | ||
| 20 | case tracker(TrackerSummary) | ||
| 21 | case mailingList(InboxMailingListReference) | ||
| 17 | } | 22 | } |
| 18 | 23 | ||
| 19 | enum AuthPhase { | 24 | enum AuthPhase { |
| @@ -48,6 +53,7 @@ final class AppState { | |||
| 48 | 53 | ||
| 49 | /// Set by the deep link handler; consumed by RootView to drive navigation. | 54 | /// Set by the deep link handler; consumed by RootView to drive navigation. |
| 50 | var pendingDeepLink: DeepLink? | 55 | var pendingDeepLink: DeepLink? |
| 56 | var pendingTabNavigation: TabNavigationTarget? | ||
| 51 | 57 | ||
| 52 | // MARK: - Init | 58 | // MARK: - Init |
| 53 | 59 | ||
| @@ -126,9 +132,9 @@ final class AppState { | |||
| 126 | // MARK: - Deep link resolution | 132 | // MARK: - Deep link resolution |
| 127 | 133 | ||
| 128 | /// Resolve a repository by owner and name for deep linking. | 134 | /// Resolve a repository by owner and name for deep linking. |
| 129 | func resolveRepository(owner: String, name: String) async throws -> RepositorySummary { | 135 | func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary { |
| 130 | let result = try await client.execute( | 136 | let result = try await client.execute( |
| 131 | service: .git, | 137 | service: service, |
| 132 | query: Self.repoLookupQuery, | 138 | query: Self.repoLookupQuery, |
| 133 | variables: ["owner": owner, "name": name], | 139 | variables: ["owner": owner, "name": name], |
| 134 | responseType: RepoLookupResponse.self | 140 | responseType: RepoLookupResponse.self |
| @@ -147,6 +153,35 @@ final class AppState { | |||
| 147 | return result.user.tracker | 153 | return result.user.tracker |
| 148 | } | 154 | } |
| 149 | 155 | ||
| 156 | func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary { | ||
| 157 | try await resolveRepository( | ||
| 158 | owner: source.ownerUsername, | ||
| 159 | name: source.name, | ||
| 160 | service: source.repoType.service | ||
| 161 | ) | ||
| 162 | } | ||
| 163 | |||
| 164 | func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary { | ||
| 165 | try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name) | ||
| 166 | } | ||
| 167 | |||
| 168 | func openProjectSource(_ source: Project.SourceRepo) async throws { | ||
| 169 | let repository = try await resolveProjectSource(source) | ||
| 170 | pendingTabNavigation = .repository(repository) | ||
| 171 | selectedTab = .repositories | ||
| 172 | } | ||
| 173 | |||
| 174 | func openProjectTracker(_ tracker: Project.Tracker) async throws { | ||
| 175 | let resolvedTracker = try await resolveProjectTracker(tracker) | ||
| 176 | pendingTabNavigation = .tracker(resolvedTracker) | ||
| 177 | selectedTab = .tickets | ||
| 178 | } | ||
| 179 | |||
| 180 | func openMailingList(_ mailingList: InboxMailingListReference) { | ||
| 181 | pendingTabNavigation = .mailingList(mailingList) | ||
| 182 | selectedTab = .more | ||
| 183 | } | ||
| 184 | |||
| 150 | // MARK: - Private | 185 | // MARK: - Private |
| 151 | 186 | ||
| 152 | private static let meQuery = """ | 187 | private static let meQuery = """ |
| @@ -221,6 +256,7 @@ final class AppState { | |||
| 221 | client.responseCache.clear() | 256 | client.responseCache.clear() |
| 222 | currentUser = nil | 257 | currentUser = nil |
| 223 | pendingDeepLink = nil | 258 | pendingDeepLink = nil |
| 259 | pendingTabNavigation = nil | ||
| 224 | selectedTab = .home | 260 | selectedTab = .home |
| 225 | } | 261 | } |
| 226 | 262 | ||
Hutch/App/RootView.swift +94 −16
| @@ -5,7 +5,7 @@ import SwiftUI | |||
| 5 | struct RootView: View { | 5 | struct RootView: View { |
| 6 | @Environment(AppState.self) private var appState | 6 | @Environment(AppState.self) private var appState |
| 7 | @State private var homePath = NavigationPath() | 7 | @State private var homePath = NavigationPath() |
| 8 | @State private var inboxPath = NavigationPath() | 8 | @State private var morePath = NavigationPath() |
| 9 | @State private var repoPath = NavigationPath() | 9 | @State private var repoPath = NavigationPath() |
| 10 | @State private var buildsPath = NavigationPath() | 10 | @State private var buildsPath = NavigationPath() |
| 11 | @State private var ticketsPath = NavigationPath() | 11 | @State private var ticketsPath = NavigationPath() |
| @@ -34,6 +34,9 @@ struct RootView: View { | |||
| 34 | .onChange(of: appState.authPhase) { _, newPhase in | 34 | .onChange(of: appState.authPhase) { _, newPhase in |
| 35 | handleAuthPhaseChange(newPhase) | 35 | handleAuthPhaseChange(newPhase) |
| 36 | } | 36 | } |
| 37 | .onChange(of: appState.pendingTabNavigation) { _, newValue in | ||
| 38 | consumePendingTabNavigationIfPossible(newValue) | ||
| 39 | } | ||
| 37 | } | 40 | } |
| 38 | 41 | ||
| 39 | // MARK: - Tab View | 42 | // MARK: - Tab View |
| @@ -50,14 +53,6 @@ struct RootView: View { | |||
| 50 | Label("Home", systemImage: "house") | 53 | Label("Home", systemImage: "house") |
| 51 | } | 54 | } |
| 52 | 55 | ||
| 53 | NavigationStack(path: $inboxPath) { | ||
| 54 | InboxView() | ||
| 55 | } | ||
| 56 | .tag(AppState.Tab.inbox) | ||
| 57 | .tabItem { | ||
| 58 | Label("Inbox", systemImage: "tray") | ||
| 59 | } | ||
| 60 | |||
| 61 | NavigationStack(path: $repoPath) { | 56 | NavigationStack(path: $repoPath) { |
| 62 | RepositoryListView() | 57 | RepositoryListView() |
| 63 | } | 58 | } |
| @@ -78,12 +73,6 @@ struct RootView: View { | |||
| 78 | Label("Tickets", systemImage: "ticket") | 73 | Label("Tickets", systemImage: "ticket") |
| 79 | } | 74 | } |
| 80 | 75 | ||
| 81 | SettingsView() | ||
| 82 | .tag(AppState.Tab.settings) | ||
| 83 | .tabItem { | ||
| 84 | Label("Settings", systemImage: "gear") | ||
| 85 | } | ||
| 86 | |||
| 87 | NavigationStack(path: $buildsPath) { | 76 | NavigationStack(path: $buildsPath) { |
| 88 | BuildListView() | 77 | BuildListView() |
| 89 | // Int destination used by deep links (hutch://builds/<id>). | 78 | // Int destination used by deep links (hutch://builds/<id>). |
| @@ -96,6 +85,14 @@ struct RootView: View { | |||
| 96 | .tabItem { | 85 | .tabItem { |
| 97 | Label("Builds", systemImage: "hammer") | 86 | Label("Builds", systemImage: "hammer") |
| 98 | } | 87 | } |
| 88 | |||
| 89 | NavigationStack(path: $morePath) { | ||
| 90 | MoreNavigationRoot() | ||
| 91 | } | ||
| 92 | .tag(AppState.Tab.more) | ||
| 93 | .tabItem { | ||
| 94 | Label("More", systemImage: "ellipsis.circle") | ||
| 95 | } | ||
| 99 | } | 96 | } |
| 100 | .overlay { | 97 | .overlay { |
| 101 | if isResolvingDeepLink { | 98 | if isResolvingDeepLink { |
| @@ -118,7 +115,7 @@ struct RootView: View { | |||
| 118 | break | 115 | break |
| 119 | case .unauthenticated: | 116 | case .unauthenticated: |
| 120 | homePath = NavigationPath() | 117 | homePath = NavigationPath() |
| 121 | inboxPath = NavigationPath() | 118 | morePath = NavigationPath() |
| 122 | repoPath = NavigationPath() | 119 | repoPath = NavigationPath() |
| 123 | buildsPath = NavigationPath() | 120 | buildsPath = NavigationPath() |
| 124 | ticketsPath = NavigationPath() | 121 | ticketsPath = NavigationPath() |
| @@ -135,6 +132,12 @@ struct RootView: View { | |||
| 135 | appState.pendingDeepLink = nil | 132 | appState.pendingDeepLink = nil |
| 136 | } | 133 | } |
| 137 | 134 | ||
| 135 | private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) { | ||
| 136 | guard appState.isAuthenticated, let target else { return } | ||
| 137 | handleTabNavigation(target) | ||
| 138 | appState.pendingTabNavigation = nil | ||
| 139 | } | ||
| 140 | |||
| 138 | private func handleDeepLink(_ link: DeepLink) { | 141 | private func handleDeepLink(_ link: DeepLink) { |
| 139 | guard appState.isAuthenticated else { return } | 142 | guard appState.isAuthenticated else { return } |
| 140 | 143 | ||
| @@ -157,6 +160,36 @@ struct RootView: View { | |||
| 157 | } | 160 | } |
| 158 | } | 161 | } |
| 159 | 162 | ||
| 163 | private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { | ||
| 164 | switch target { | ||
| 165 | case .repository(let repository): | ||
| 166 | repoPath = NavigationPath() | ||
| 167 | appState.selectedTab = .repositories | ||
| 168 | Task { @MainActor in | ||
| 169 | try? await Task.sleep(for: .milliseconds(100)) | ||
| 170 | repoPath.append(repository) | ||
| 171 | } | ||
| 172 | |||
| 173 | case .tracker(let tracker): | ||
| 174 | ticketsPath = NavigationPath() | ||
| 175 | appState.selectedTab = .tickets | ||
| 176 | Task { @MainActor in | ||
| 177 | try? await Task.sleep(for: .milliseconds(100)) | ||
| 178 | ticketsPath.append(tracker) | ||
| 179 | } | ||
| 180 | |||
| 181 | case .mailingList(let mailingList): | ||
| 182 | morePath = NavigationPath() | ||
| 183 | appState.selectedTab = .more | ||
| 184 | Task { @MainActor in | ||
| 185 | try? await Task.sleep(for: .milliseconds(100)) | ||
| 186 | morePath.append(MoreRoute.lists) | ||
| 187 | try? await Task.sleep(for: .milliseconds(100)) | ||
| 188 | morePath.append(MoreRoute.mailingList(mailingList)) | ||
| 189 | } | ||
| 190 | } | ||
| 191 | } | ||
| 192 | |||
| 160 | private func resolveRepositoryLink(owner: String, repo: String) { | 193 | private func resolveRepositoryLink(owner: String, repo: String) { |
| 161 | isResolvingDeepLink = true | 194 | isResolvingDeepLink = true |
| 162 | Task { | 195 | Task { |
| @@ -198,6 +231,51 @@ struct RootView: View { | |||
| 198 | } | 231 | } |
| 199 | } | 232 | } |
| 200 | 233 | ||
| 234 | enum MoreDestination: Hashable { | ||
| 235 | case lists | ||
| 236 | case pastes | ||
| 237 | case settings | ||
| 238 | } | ||
| 239 | |||
| 240 | enum MoreRoute: Hashable { | ||
| 241 | case lists | ||
| 242 | case pastes | ||
| 243 | case settings | ||
| 244 | case mailingList(InboxMailingListReference) | ||
| 245 | case thread(InboxThreadSummary) | ||
| 246 | } | ||
| 247 | |||
| 248 | private struct MoreNavigationRoot: View { | ||
| 249 | var body: some View { | ||
| 250 | MoreView() | ||
| 251 | .navigationDestination(for: MoreRoute.self) { route in | ||
| 252 | switch route { | ||
| 253 | case .lists: | ||
| 254 | MailingListListView() | ||
| 255 | case .pastes: | ||
| 256 | PasteListView() | ||
| 257 | case .settings: | ||
| 258 | SettingsView() | ||
| 259 | case .mailingList(let mailingList): | ||
| 260 | MailingListDetailView(mailingList: mailingList) | ||
| 261 | case .thread(let thread): | ||
| 262 | ThreadDetailView( | ||
| 263 | thread: thread, | ||
| 264 | onViewed: { | ||
| 265 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | ||
| 266 | }, | ||
| 267 | onMarkRead: { | ||
| 268 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | ||
| 269 | }, | ||
| 270 | onMarkUnread: { | ||
| 271 | InboxReadStateStore.markUnread(for: thread.id) | ||
| 272 | } | ||
| 273 | ) | ||
| 274 | } | ||
| 275 | } | ||
| 276 | } | ||
| 277 | } | ||
| 278 | |||
| 201 | // MARK: - Ticket Deep Link Navigation Target | 279 | // MARK: - Ticket Deep Link Navigation Target |
| 202 | 280 | ||
| 203 | /// Hashable wrapper to push a ticket detail view from a deep link. | 281 | /// Hashable wrapper to push a ticket detail view from a deep link. |
Hutch/Extensions/SRHTShareUI.swift +1
| @@ -9,6 +9,7 @@ enum SRHTShareTarget: String { | |||
| 9 | case tracker = "tracker" | 9 | case tracker = "tracker" |
| 10 | case ticket = "ticket" | 10 | case ticket = "ticket" |
| 11 | case profile = "profile" | 11 | case profile = "profile" |
| 12 | case paste = "paste" | ||
| 12 | 13 | ||
| 13 | var fallbackMessage: String { | 14 | var fallbackMessage: String { |
| 14 | "This \(rawValue) does not have a valid web URL to share." | 15 | "This \(rawValue) does not have a valid web URL to share." |
Hutch/Extensions/SRHTWebURL.swift +8
| @@ -76,6 +76,14 @@ enum SRHTWebURL { | |||
| 76 | ) | 76 | ) |
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | static func paste(ownerCanonicalName: String, pasteId: String) -> URL? { | ||
| 80 | userScopedURL( | ||
| 81 | host: "paste.sr.ht", | ||
| 82 | ownerCanonicalName: ownerCanonicalName, | ||
| 83 | pathComponents: [pasteId] | ||
| 84 | ) | ||
| 85 | } | ||
| 86 | |||
| 79 | private static func userScopedURL( | 87 | private static func userScopedURL( |
| 80 | host: String, | 88 | host: String, |
| 81 | ownerCanonicalName: String, | 89 | ownerCanonicalName: String, |
Hutch/Models/Inbox.swift +1 −1
| @@ -150,7 +150,7 @@ struct MailComposeDraft: Sendable { | |||
| 150 | 150 | ||
| 151 | extension MailComposeDraft: Identifiable {} | 151 | extension MailComposeDraft: Identifiable {} |
| 152 | 152 | ||
| 153 | struct InboxMailingListReference: Decodable, Sendable, Hashable { | 153 | struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable { |
| 154 | let id: Int | 154 | let id: Int |
| 155 | let rid: String | 155 | let rid: String |
| 156 | let name: String | 156 | let name: String |
Hutch/Models/Paste.swift added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct PasteFile: Codable, Sendable, Hashable, Identifiable { | ||
| 4 | let filename: String? | ||
| 5 | let hash: String | ||
| 6 | let contents: URL? | ||
| 7 | |||
| 8 | var id: String { hash } | ||
| 9 | } | ||
| 10 | |||
| 11 | struct Paste: Codable, Sendable, Identifiable, Hashable { | ||
| 12 | let id: String | ||
| 13 | let created: Date | ||
| 14 | let visibility: Visibility | ||
| 15 | let files: [PasteFile] | ||
| 16 | let user: Entity | ||
| 17 | } | ||
| 18 | |||
| 19 | struct PasteUploadDraft: Identifiable, Equatable, Sendable { | ||
| 20 | let id: UUID | ||
| 21 | var filename: String | ||
| 22 | var contents: String | ||
| 23 | |||
| 24 | init(id: UUID = UUID(), filename: String = "", contents: String = "") { | ||
| 25 | self.id = id | ||
| 26 | self.filename = filename | ||
| 27 | self.contents = contents | ||
| 28 | } | ||
| 29 | } | ||
Hutch/Models/Project.swift added +101
| @@ -0,0 +1,101 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct Project: Identifiable, Hashable, Sendable { | ||
| 4 | struct MailingList: Identifiable, Hashable, Sendable { | ||
| 5 | let id: String | ||
| 6 | let name: String | ||
| 7 | let description: String? | ||
| 8 | let visibility: Visibility | ||
| 9 | let owner: Entity | ||
| 10 | |||
| 11 | var ownerUsername: String { | ||
| 12 | owner.canonicalName.srhtUsername | ||
| 13 | } | ||
| 14 | |||
| 15 | var inboxReference: InboxMailingListReference { | ||
| 16 | InboxMailingListReference( | ||
| 17 | id: 0, | ||
| 18 | rid: id, | ||
| 19 | name: name, | ||
| 20 | owner: owner | ||
| 21 | ) | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | struct SourceRepo: Identifiable, Hashable, Sendable { | ||
| 26 | enum RepoType: String, Decodable, Sendable { | ||
| 27 | case git = "GIT" | ||
| 28 | case hg = "HG" | ||
| 29 | |||
| 30 | var service: SRHTService { | ||
| 31 | switch self { | ||
| 32 | case .git: .git | ||
| 33 | case .hg: .hg | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | let id: String | ||
| 39 | let name: String | ||
| 40 | let description: String? | ||
| 41 | let visibility: Visibility | ||
| 42 | let owner: Entity | ||
| 43 | let repoType: RepoType | ||
| 44 | |||
| 45 | var ownerUsername: String { | ||
| 46 | owner.canonicalName.srhtUsername | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | struct Tracker: Identifiable, Hashable, Sendable { | ||
| 51 | let id: String | ||
| 52 | let name: String | ||
| 53 | let description: String? | ||
| 54 | let visibility: Visibility | ||
| 55 | let owner: Entity | ||
| 56 | |||
| 57 | var ownerUsername: String { | ||
| 58 | owner.canonicalName.srhtUsername | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | let id: String | ||
| 63 | let name: String | ||
| 64 | let description: String? | ||
| 65 | let website: String? | ||
| 66 | let visibility: Visibility | ||
| 67 | let tags: [String] | ||
| 68 | let mailingLists: [MailingList] | ||
| 69 | let sources: [SourceRepo] | ||
| 70 | let trackers: [Tracker] | ||
| 71 | |||
| 72 | var resourceSummary: String? { | ||
| 73 | let parts = [ | ||
| 74 | Self.resourceCountText(count: sources.count, singular: "repo"), | ||
| 75 | Self.resourceCountText(count: trackers.count, singular: "tracker"), | ||
| 76 | Self.resourceCountText(count: mailingLists.count, singular: "list") | ||
| 77 | ].compactMap { $0 } | ||
| 78 | |||
| 79 | if !parts.isEmpty { | ||
| 80 | return parts.joined(separator: " • ") | ||
| 81 | } | ||
| 82 | |||
| 83 | if let website, !website.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | ||
| 84 | return "Website linked" | ||
| 85 | } | ||
| 86 | |||
| 87 | return nil | ||
| 88 | } | ||
| 89 | |||
| 90 | private static func resourceCountText(count: Int, singular: String) -> String? { | ||
| 91 | guard count > 0 else { return nil } | ||
| 92 | let label = count == 1 ? singular : "\(singular)s" | ||
| 93 | return "\(count) \(label)" | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | extension String { | ||
| 98 | var srhtUsername: String { | ||
| 99 | hasPrefix("~") ? String(dropFirst()) : self | ||
| 100 | } | ||
| 101 | } | ||
Hutch/Networking/GraphQLRequest.swift +2
| @@ -34,6 +34,8 @@ struct AnyCodable: Sendable, Encodable { | |||
| 34 | try container.encode(v) | 34 | try container.encode(v) |
| 35 | case let v as Bool: | 35 | case let v as Bool: |
| 36 | try container.encode(v) | 36 | try container.encode(v) |
| 37 | case let v as [String?]: | ||
| 38 | try container.encode(v) | ||
| 37 | case let v as [any Sendable]: | 39 | case let v as [any Sendable]: |
| 38 | try container.encode(v.map { AnyCodable($0) }) | 40 | try container.encode(v.map { AnyCodable($0) }) |
| 39 | case let v as [String: any Sendable]: | 41 | case let v as [String: any Sendable]: |
Hutch/Networking/PasteService.swift added +231
| @@ -0,0 +1,231 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct PasteListPage: Decodable, Sendable { | ||
| 4 | let results: [Paste] | ||
| 5 | let cursor: String? | ||
| 6 | } | ||
| 7 | |||
| 8 | final class PasteService: Sendable { | ||
| 9 | private let client: SRHTClient | ||
| 10 | |||
| 11 | init(client: SRHTClient) { | ||
| 12 | self.client = client | ||
| 13 | } | ||
| 14 | |||
| 15 | private static let listQuery = """ | ||
| 16 | query pastes($cursor: Cursor) { | ||
| 17 | pastes(cursor: $cursor) { | ||
| 18 | results { | ||
| 19 | id | ||
| 20 | created | ||
| 21 | visibility | ||
| 22 | files { | ||
| 23 | filename | ||
| 24 | hash | ||
| 25 | } | ||
| 26 | user { | ||
| 27 | canonicalName | ||
| 28 | } | ||
| 29 | } | ||
| 30 | cursor | ||
| 31 | } | ||
| 32 | } | ||
| 33 | """ | ||
| 34 | |||
| 35 | private static let detailQuery = """ | ||
| 36 | query paste($id: String!) { | ||
| 37 | paste(id: $id) { | ||
| 38 | id | ||
| 39 | created | ||
| 40 | visibility | ||
| 41 | files { | ||
| 42 | filename | ||
| 43 | hash | ||
| 44 | contents | ||
| 45 | } | ||
| 46 | user { | ||
| 47 | canonicalName | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | """ | ||
| 52 | |||
| 53 | private static let createMutation = """ | ||
| 54 | mutation createPaste($files: [Upload!]!, $visibility: Visibility!) { | ||
| 55 | create(files: $files, visibility: $visibility) { | ||
| 56 | id | ||
| 57 | created | ||
| 58 | visibility | ||
| 59 | files { | ||
| 60 | filename | ||
| 61 | hash | ||
| 62 | contents | ||
| 63 | } | ||
| 64 | user { | ||
| 65 | canonicalName | ||
| 66 | } | ||
| 67 | } | ||
| 68 | } | ||
| 69 | """ | ||
| 70 | |||
| 71 | private static let updateMutation = """ | ||
| 72 | mutation updatePaste($id: String!, $visibility: Visibility!) { | ||
| 73 | update(id: $id, visibility: $visibility) { | ||
| 74 | id | ||
| 75 | created | ||
| 76 | visibility | ||
| 77 | files { | ||
| 78 | filename | ||
| 79 | hash | ||
| 80 | contents | ||
| 81 | } | ||
| 82 | user { | ||
| 83 | canonicalName | ||
| 84 | } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | """ | ||
| 88 | |||
| 89 | private static let deleteMutation = """ | ||
| 90 | mutation deletePaste($id: String!) { | ||
| 91 | delete(id: $id) { | ||
| 92 | id | ||
| 93 | created | ||
| 94 | visibility | ||
| 95 | files { | ||
| 96 | filename | ||
| 97 | hash | ||
| 98 | } | ||
| 99 | user { | ||
| 100 | canonicalName | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | """ | ||
| 105 | |||
| 106 | private static let cacheKey = "paste.pastes" | ||
| 107 | |||
| 108 | func listPastes(cursor: String?, useCache: Bool) async throws -> PasteListPage { | ||
| 109 | let variables = cursor.map { ["cursor": $0 as any Sendable] } | ||
| 110 | let result: PasteListResponse | ||
| 111 | if useCache, cursor == nil { | ||
| 112 | result = try await client.executeAndCache( | ||
| 113 | service: .paste, | ||
| 114 | query: Self.listQuery, | ||
| 115 | variables: variables, | ||
| 116 | responseType: PasteListResponse.self, | ||
| 117 | cacheKey: Self.cacheKey | ||
| 118 | ) | ||
| 119 | } else { | ||
| 120 | result = try await client.execute( | ||
| 121 | service: .paste, | ||
| 122 | query: Self.listQuery, | ||
| 123 | variables: variables, | ||
| 124 | responseType: PasteListResponse.self | ||
| 125 | ) | ||
| 126 | } | ||
| 127 | return result.pastes ?? PasteListPage(results: [], cursor: nil) | ||
| 128 | } | ||
| 129 | |||
| 130 | func loadCachedPastes() -> PasteListPage? { | ||
| 131 | guard let data = client.responseCache.get(forKey: Self.cacheKey) else { | ||
| 132 | return nil | ||
| 133 | } | ||
| 134 | |||
| 135 | let decoder = JSONDecoder() | ||
| 136 | decoder.dateDecodingStrategy = .srhtFlexible | ||
| 137 | guard let response = try? decoder.decode(GraphQLResponse<PasteListResponse>.self, from: data) else { | ||
| 138 | return nil | ||
| 139 | } | ||
| 140 | return response.data?.pastes | ||
| 141 | } | ||
| 142 | |||
| 143 | func loadPaste(id: String) async throws -> Paste? { | ||
| 144 | let result = try await client.execute( | ||
| 145 | service: .paste, | ||
| 146 | query: Self.detailQuery, | ||
| 147 | variables: ["id": id], | ||
| 148 | responseType: PasteDetailResponse.self | ||
| 149 | ) | ||
| 150 | return result.paste | ||
| 151 | } | ||
| 152 | |||
| 153 | func createPaste(files: [PasteUploadDraft], visibility: Visibility) async throws -> Paste { | ||
| 154 | let uploadFiles = normalizedUploadFiles(from: files) | ||
| 155 | let variables: [String: any Sendable] = [ | ||
| 156 | "files": [String?](repeating: nil, count: uploadFiles.count), | ||
| 157 | "visibility": visibility.rawValue | ||
| 158 | ] | ||
| 159 | |||
| 160 | let result = try await client.executeMultipartFiles( | ||
| 161 | service: .paste, | ||
| 162 | query: Self.createMutation, | ||
| 163 | variables: variables, | ||
| 164 | files: uploadFiles.enumerated().map { index, file in | ||
| 165 | MultipartUploadFile( | ||
| 166 | variablePath: "files.\(index)", | ||
| 167 | fileData: file.data, | ||
| 168 | fileName: file.fileName, | ||
| 169 | mimeType: "text/plain" | ||
| 170 | ) | ||
| 171 | }, | ||
| 172 | responseType: CreatePasteResponse.self | ||
| 173 | ) | ||
| 174 | return result.create | ||
| 175 | } | ||
| 176 | |||
| 177 | func updateVisibility(id: String, visibility: Visibility) async throws -> Paste? { | ||
| 178 | let result = try await client.execute( | ||
| 179 | service: .paste, | ||
| 180 | query: Self.updateMutation, | ||
| 181 | variables: ["id": id, "visibility": visibility.rawValue], | ||
| 182 | responseType: UpdatePasteResponse.self | ||
| 183 | ) | ||
| 184 | return result.update | ||
| 185 | } | ||
| 186 | |||
| 187 | func deletePaste(id: String) async throws -> Paste? { | ||
| 188 | let result = try await client.execute( | ||
| 189 | service: .paste, | ||
| 190 | query: Self.deleteMutation, | ||
| 191 | variables: ["id": id], | ||
| 192 | responseType: DeletePasteResponse.self | ||
| 193 | ) | ||
| 194 | return result.delete | ||
| 195 | } | ||
| 196 | |||
| 197 | func loadContents(from url: URL) async throws -> String { | ||
| 198 | try await client.fetchText(url: url) | ||
| 199 | } | ||
| 200 | |||
| 201 | private func normalizedUploadFiles(from files: [PasteUploadDraft]) -> [(fileName: String, data: Data)] { | ||
| 202 | files.compactMap { draft in | ||
| 203 | let text = draft.contents | ||
| 204 | guard let data = text.data(using: .utf8) else { | ||
| 205 | return nil | ||
| 206 | } | ||
| 207 | |||
| 208 | return (draft.filename, data) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 213 | private struct PasteListResponse: Decodable, Sendable { | ||
| 214 | let pastes: PasteListPage? | ||
| 215 | } | ||
| 216 | |||
| 217 | private struct PasteDetailResponse: Decodable, Sendable { | ||
| 218 | let paste: Paste? | ||
| 219 | } | ||
| 220 | |||
| 221 | private struct CreatePasteResponse: Decodable, Sendable { | ||
| 222 | let create: Paste | ||
| 223 | } | ||
| 224 | |||
| 225 | private struct UpdatePasteResponse: Decodable, Sendable { | ||
| 226 | let update: Paste? | ||
| 227 | } | ||
| 228 | |||
| 229 | private struct DeletePasteResponse: Decodable, Sendable { | ||
| 230 | let delete: Paste? | ||
| 231 | } | ||
Hutch/Networking/ProjectService.swift added +284
| @@ -0,0 +1,284 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | private struct ProjectPageResponse: Decodable, Sendable { | ||
| 4 | let me: ProjectPageUser | ||
| 5 | } | ||
| 6 | |||
| 7 | private struct ProjectPageUser: Decodable, Sendable { | ||
| 8 | let projects: ProjectPage | ||
| 9 | } | ||
| 10 | |||
| 11 | private struct ProjectPage: Decodable, Sendable { | ||
| 12 | let results: [ProjectSummaryPayload] | ||
| 13 | let cursor: String? | ||
| 14 | } | ||
| 15 | |||
| 16 | private struct ProjectSummaryPayload: Decodable, Sendable { | ||
| 17 | let rid: String | ||
| 18 | let name: String | ||
| 19 | let description: String? | ||
| 20 | let website: String? | ||
| 21 | let visibility: Visibility | ||
| 22 | let tags: [String] | ||
| 23 | } | ||
| 24 | |||
| 25 | private struct ProjectDetailResponse: Decodable, Sendable { | ||
| 26 | let project: ProjectDetailPayload? | ||
| 27 | } | ||
| 28 | |||
| 29 | private struct ProjectDetailPayload: Decodable, Sendable { | ||
| 30 | let rid: String | ||
| 31 | let name: String | ||
| 32 | let description: String? | ||
| 33 | let website: String? | ||
| 34 | let visibility: Visibility | ||
| 35 | let tags: [String] | ||
| 36 | let mailingLists: ProjectMailingListPage | ||
| 37 | let sources: ProjectSourcePage | ||
| 38 | let trackers: ProjectTrackerPage | ||
| 39 | } | ||
| 40 | |||
| 41 | private struct ProjectMailingListPage: Decodable, Sendable { | ||
| 42 | let results: [ProjectMailingListPayload] | ||
| 43 | let cursor: String? | ||
| 44 | } | ||
| 45 | |||
| 46 | private struct ProjectMailingListPayload: Decodable, Sendable { | ||
| 47 | let rid: String | ||
| 48 | let name: String | ||
| 49 | let description: String? | ||
| 50 | let visibility: Visibility | ||
| 51 | let owner: Entity | ||
| 52 | } | ||
| 53 | |||
| 54 | private struct ProjectSourcePage: Decodable, Sendable { | ||
| 55 | let results: [ProjectSourcePayload] | ||
| 56 | let cursor: String? | ||
| 57 | } | ||
| 58 | |||
| 59 | private struct ProjectSourcePayload: Decodable, Sendable { | ||
| 60 | let rid: String | ||
| 61 | let name: String | ||
| 62 | let description: String? | ||
| 63 | let visibility: Visibility | ||
| 64 | let owner: Entity | ||
| 65 | let repoType: Project.SourceRepo.RepoType | ||
| 66 | } | ||
| 67 | |||
| 68 | private struct ProjectTrackerPage: Decodable, Sendable { | ||
| 69 | let results: [ProjectTrackerPayload] | ||
| 70 | let cursor: String? | ||
| 71 | } | ||
| 72 | |||
| 73 | private struct ProjectTrackerPayload: Decodable, Sendable { | ||
| 74 | let rid: String | ||
| 75 | let name: String | ||
| 76 | let description: String? | ||
| 77 | let visibility: Visibility | ||
| 78 | let owner: Entity | ||
| 79 | } | ||
| 80 | |||
| 81 | struct ProjectService: Sendable { | ||
| 82 | private let client: SRHTClient | ||
| 83 | |||
| 84 | private static let projectsQuery = """ | ||
| 85 | query meProjects($cursor: Cursor) { | ||
| 86 | me { | ||
| 87 | projects(cursor: $cursor) { | ||
| 88 | results { | ||
| 89 | rid | ||
| 90 | name | ||
| 91 | description | ||
| 92 | website | ||
| 93 | visibility | ||
| 94 | tags | ||
| 95 | } | ||
| 96 | cursor | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | """ | ||
| 101 | |||
| 102 | private static let projectDetailQuery = """ | ||
| 103 | query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) { | ||
| 104 | project(rid: $rid) { | ||
| 105 | rid | ||
| 106 | name | ||
| 107 | description | ||
| 108 | website | ||
| 109 | visibility | ||
| 110 | tags | ||
| 111 | mailingLists(cursor: $mailingListsCursor) { | ||
| 112 | results { | ||
| 113 | rid | ||
| 114 | name | ||
| 115 | description | ||
| 116 | visibility | ||
| 117 | owner { canonicalName } | ||
| 118 | } | ||
| 119 | cursor | ||
| 120 | } | ||
| 121 | sources(cursor: $sourcesCursor) { | ||
| 122 | results { | ||
| 123 | rid | ||
| 124 | name | ||
| 125 | description | ||
| 126 | visibility | ||
| 127 | owner { canonicalName } | ||
| 128 | repoType | ||
| 129 | } | ||
| 130 | cursor | ||
| 131 | } | ||
| 132 | trackers(cursor: $trackersCursor) { | ||
| 133 | results { | ||
| 134 | rid | ||
| 135 | name | ||
| 136 | description | ||
| 137 | visibility | ||
| 138 | owner { canonicalName } | ||
| 139 | } | ||
| 140 | cursor | ||
| 141 | } | ||
| 142 | } | ||
| 143 | } | ||
| 144 | """ | ||
| 145 | |||
| 146 | init(client: SRHTClient) { | ||
| 147 | self.client = client | ||
| 148 | } | ||
| 149 | |||
| 150 | func fetchProjects() async throws -> [Project] { | ||
| 151 | let summaries = try await fetchProjectSummaries() | ||
| 152 | guard !summaries.isEmpty else { return [] } | ||
| 153 | |||
| 154 | return try await withThrowingTaskGroup(of: Project.self) { group in | ||
| 155 | for summary in summaries { | ||
| 156 | group.addTask { | ||
| 157 | try await self.fetchProjectDetail(summary: summary) | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | var projects: [Project] = [] | ||
| 162 | for try await project in group { | ||
| 163 | projects.append(project) | ||
| 164 | } | ||
| 165 | return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] { | ||
| 170 | var results: [ProjectSummaryPayload] = [] | ||
| 171 | var cursor: String? | ||
| 172 | |||
| 173 | while true { | ||
| 174 | var variables: [String: any Sendable] = [:] | ||
| 175 | if let cursor { | ||
| 176 | variables["cursor"] = cursor | ||
| 177 | } | ||
| 178 | |||
| 179 | let response = try await client.execute( | ||
| 180 | service: .hub, | ||
| 181 | query: Self.projectsQuery, | ||
| 182 | variables: variables.isEmpty ? nil : variables, | ||
| 183 | responseType: ProjectPageResponse.self | ||
| 184 | ) | ||
| 185 | |||
| 186 | results.append(contentsOf: response.me.projects.results) | ||
| 187 | guard let nextCursor = response.me.projects.cursor else { | ||
| 188 | break | ||
| 189 | } | ||
| 190 | cursor = nextCursor | ||
| 191 | } | ||
| 192 | |||
| 193 | return results | ||
| 194 | } | ||
| 195 | |||
| 196 | private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project { | ||
| 197 | var mailingLists: [Project.MailingList] = [] | ||
| 198 | var sources: [Project.SourceRepo] = [] | ||
| 199 | var trackers: [Project.Tracker] = [] | ||
| 200 | var mailingListsCursor: String? | ||
| 201 | var sourcesCursor: String? | ||
| 202 | var trackersCursor: String? | ||
| 203 | |||
| 204 | while true { | ||
| 205 | var variables: [String: any Sendable] = ["rid": summary.rid] | ||
| 206 | if let mailingListsCursor { | ||
| 207 | variables["mailingListsCursor"] = mailingListsCursor | ||
| 208 | } | ||
| 209 | if let sourcesCursor { | ||
| 210 | variables["sourcesCursor"] = sourcesCursor | ||
| 211 | } | ||
| 212 | if let trackersCursor { | ||
| 213 | variables["trackersCursor"] = trackersCursor | ||
| 214 | } | ||
| 215 | |||
| 216 | let response = try await client.execute( | ||
| 217 | service: .hub, | ||
| 218 | query: Self.projectDetailQuery, | ||
| 219 | variables: variables, | ||
| 220 | responseType: ProjectDetailResponse.self | ||
| 221 | ) | ||
| 222 | |||
| 223 | guard let project = response.project else { | ||
| 224 | throw SRHTError.decodingError( | ||
| 225 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload")) | ||
| 226 | ) | ||
| 227 | } | ||
| 228 | |||
| 229 | mailingLists.append(contentsOf: project.mailingLists.results.map { | ||
| 230 | Project.MailingList( | ||
| 231 | id: $0.rid, | ||
| 232 | name: $0.name, | ||
| 233 | description: $0.description, | ||
| 234 | visibility: $0.visibility, | ||
| 235 | owner: $0.owner | ||
| 236 | ) | ||
| 237 | }) | ||
| 238 | sources.append(contentsOf: project.sources.results.map { | ||
| 239 | Project.SourceRepo( | ||
| 240 | id: $0.rid, | ||
| 241 | name: $0.name, | ||
| 242 | description: $0.description, | ||
| 243 | visibility: $0.visibility, | ||
| 244 | owner: $0.owner, | ||
| 245 | repoType: $0.repoType | ||
| 246 | ) | ||
| 247 | }) | ||
| 248 | trackers.append(contentsOf: project.trackers.results.map { | ||
| 249 | Project.Tracker( | ||
| 250 | id: $0.rid, | ||
| 251 | name: $0.name, | ||
| 252 | description: $0.description, | ||
| 253 | visibility: $0.visibility, | ||
| 254 | owner: $0.owner | ||
| 255 | ) | ||
| 256 | }) | ||
| 257 | |||
| 258 | mailingListsCursor = project.mailingLists.cursor | ||
| 259 | sourcesCursor = project.sources.cursor | ||
| 260 | trackersCursor = project.trackers.cursor | ||
| 261 | |||
| 262 | if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil { | ||
| 263 | return Project( | ||
| 264 | id: project.rid, | ||
| 265 | name: project.name, | ||
| 266 | description: project.description, | ||
| 267 | website: project.website, | ||
| 268 | visibility: project.visibility, | ||
| 269 | tags: project.tags, | ||
| 270 | mailingLists: deduplicate(mailingLists), | ||
| 271 | sources: deduplicate(sources), | ||
| 272 | trackers: deduplicate(trackers) | ||
| 273 | ) | ||
| 274 | } | ||
| 275 | } | ||
| 276 | } | ||
| 277 | |||
| 278 | private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable { | ||
| 279 | var seen = Set<T.ID>() | ||
| 280 | return items.filter { item in | ||
| 281 | seen.insert(item.id).inserted | ||
| 282 | } | ||
| 283 | } | ||
| 284 | } | ||
Hutch/Networking/SRHTClient.swift +101
| @@ -3,6 +3,13 @@ import os | |||
| 3 | 3 | ||
| 4 | private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient") | 4 | private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient") |
| 5 | 5 | ||
| 6 | struct MultipartUploadFile: Sendable { | ||
| 7 | let variablePath: String | ||
| 8 | let fileData: Data | ||
| 9 | let fileName: String | ||
| 10 | let mimeType: String | ||
| 11 | } | ||
| 12 | |||
| 6 | /// Placeholder type for decoding GraphQL error responses when the data shape is unknown. | 13 | /// Placeholder type for decoding GraphQL error responses when the data shape is unknown. |
| 7 | private struct EmptyData: Decodable {} | 14 | private struct EmptyData: Decodable {} |
| 8 | 15 | ||
| @@ -298,6 +305,100 @@ final class SRHTClient: Sendable { | |||
| 298 | return result | 305 | return result |
| 299 | } | 306 | } |
| 300 | 307 | ||
| 308 | func executeMultipartFiles<T: Decodable>( | ||
| 309 | service: SRHTService, | ||
| 310 | query: String, | ||
| 311 | variables: [String: any Sendable], | ||
| 312 | files: [MultipartUploadFile], | ||
| 313 | responseType: T.Type | ||
| 314 | ) async throws -> T { | ||
| 315 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | ||
| 316 | throw SRHTError.unauthorized | ||
| 317 | } | ||
| 318 | |||
| 319 | let boundary = "Boundary-\(UUID().uuidString)" | ||
| 320 | |||
| 321 | var request = URLRequest(url: service.url) | ||
| 322 | request.httpMethod = "POST" | ||
| 323 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
| 324 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") | ||
| 325 | |||
| 326 | let operationsBody = GraphQLRequestBody( | ||
| 327 | query: query, | ||
| 328 | variables: variables.mapValues { AnyCodable($0) } | ||
| 329 | ) | ||
| 330 | let operationsData = try encoder.encode(operationsBody) | ||
| 331 | |||
| 332 | let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in | ||
| 333 | (String(index), ["variables.\(file.variablePath)"]) | ||
| 334 | }) | ||
| 335 | let mapData = try encoder.encode(mapDict) | ||
| 336 | |||
| 337 | var body = Data() | ||
| 338 | |||
| 339 | body.append("--\(boundary)\r\n") | ||
| 340 | body.append("Content-Disposition: form-data; name=\"operations\"\r\n") | ||
| 341 | body.append("Content-Type: application/json\r\n\r\n") | ||
| 342 | body.append(operationsData) | ||
| 343 | body.append("\r\n") | ||
| 344 | |||
| 345 | body.append("--\(boundary)\r\n") | ||
| 346 | body.append("Content-Disposition: form-data; name=\"map\"\r\n") | ||
| 347 | body.append("Content-Type: application/json\r\n\r\n") | ||
| 348 | body.append(mapData) | ||
| 349 | body.append("\r\n") | ||
| 350 | |||
| 351 | for (index, file) in files.enumerated() { | ||
| 352 | body.append("--\(boundary)\r\n") | ||
| 353 | body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n") | ||
| 354 | body.append("Content-Type: \(file.mimeType)\r\n\r\n") | ||
| 355 | body.append(file.fileData) | ||
| 356 | body.append("\r\n") | ||
| 357 | } | ||
| 358 | |||
| 359 | body.append("--\(boundary)--\r\n") | ||
| 360 | request.httpBody = body | ||
| 361 | |||
| 362 | let (data, response): (Data, URLResponse) | ||
| 363 | do { | ||
| 364 | (data, response) = try await session.data(for: request) | ||
| 365 | } catch { | ||
| 366 | throw SRHTError.networkError(error) | ||
| 367 | } | ||
| 368 | |||
| 369 | if let http = response as? HTTPURLResponse { | ||
| 370 | if http.statusCode == 401 { | ||
| 371 | throw SRHTError.unauthorized | ||
| 372 | } | ||
| 373 | if !(200...299).contains(http.statusCode) { | ||
| 374 | if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), | ||
| 375 | let errors = gqlResponse.errors, !errors.isEmpty { | ||
| 376 | throw SRHTError.graphQLErrors(errors) | ||
| 377 | } | ||
| 378 | throw SRHTError.httpError(http.statusCode) | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | let graphQLResponse: GraphQLResponse<T> | ||
| 383 | do { | ||
| 384 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) | ||
| 385 | } catch { | ||
| 386 | throw SRHTError.decodingError(error) | ||
| 387 | } | ||
| 388 | |||
| 389 | if let errors = graphQLResponse.errors, !errors.isEmpty { | ||
| 390 | throw SRHTError.graphQLErrors(errors) | ||
| 391 | } | ||
| 392 | |||
| 393 | guard let result = graphQLResponse.data else { | ||
| 394 | throw SRHTError.decodingError( | ||
| 395 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) | ||
| 396 | ) | ||
| 397 | } | ||
| 398 | |||
| 399 | return result | ||
| 400 | } | ||
| 401 | |||
| 301 | // MARK: - Cached Execute | 402 | // MARK: - Cached Execute |
| 302 | 403 | ||
| 303 | /// Execute a query and cache the raw response data. Returns cached data | 404 | /// Execute a query and cache the raw response data. Returns cached data |
Hutch/Networking/SRHTService.swift +9 −2
| @@ -3,6 +3,7 @@ import Foundation | |||
| 3 | /// Each Sourcehut service exposes its own GraphQL endpoint. | 3 | /// Each Sourcehut service exposes its own GraphQL endpoint. |
| 4 | enum SRHTService: String, Codable, Sendable, CaseIterable { | 4 | enum SRHTService: String, Codable, Sendable, CaseIterable { |
| 5 | case meta | 5 | case meta |
| 6 | case hub | ||
| 6 | case git | 7 | case git |
| 7 | case hg | 8 | case hg |
| 8 | case builds | 9 | case builds |
| @@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable { | |||
| 14 | 15 | ||
| 15 | /// The GraphQL endpoint URL for this service. | 16 | /// The GraphQL endpoint URL for this service. |
| 16 | var url: URL { | 17 | var url: URL { |
| 17 | // Force-unwrap is safe here — these are compile-time constant strings. | 18 | switch self { |
| 18 | URL(string: "https://\(rawValue).sr.ht/query")! | 19 | case .hub: |
| 20 | URL(string: "https://sr.ht/query")! | ||
| 21 | default: | ||
| 22 | // Force-unwrap is safe here — these are compile-time constant strings. | ||
| 23 | URL(string: "https://\(rawValue).sr.ht/query")! | ||
| 24 | } | ||
| 19 | } | 25 | } |
| 20 | 26 | ||
| 21 | var displayName: String { | 27 | var displayName: String { |
| 22 | switch self { | 28 | switch self { |
| 23 | case .meta: "Meta" | 29 | case .meta: "Meta" |
| 30 | case .hub: "Hub" | ||
| 24 | case .git: "Git" | 31 | case .git: "Git" |
| 25 | case .hg: "Mercurial" | 32 | case .hg: "Mercurial" |
| 26 | case .builds: "Builds" | 33 | case .builds: "Builds" |
Hutch/Views/Home/HomeView.swift +105 −4
| @@ -4,6 +4,7 @@ struct HomeView: View { | |||
| 4 | @Environment(AppState.self) private var appState | 4 | @Environment(AppState.self) private var appState |
| 5 | @State private var viewModel: HomeViewModel? | 5 | @State private var viewModel: HomeViewModel? |
| 6 | private let previewLimit = 4 | 6 | private let previewLimit = 4 |
| 7 | private let projectPreviewLimit = 3 | ||
| 7 | 8 | ||
| 8 | var body: some View { | 9 | var body: some View { |
| 9 | Group { | 10 | Group { |
| @@ -14,6 +15,15 @@ struct HomeView: View { | |||
| 14 | } | 15 | } |
| 15 | } | 16 | } |
| 16 | .navigationTitle("Home") | 17 | .navigationTitle("Home") |
| 18 | .toolbar { | ||
| 19 | ToolbarItem(placement: .topBarTrailing) { | ||
| 20 | NavigationLink { | ||
| 21 | InboxView() | ||
| 22 | } label: { | ||
| 23 | HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true) | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 17 | .task { | 27 | .task { |
| 18 | if viewModel == nil, let currentUser = appState.currentUser { | 28 | if viewModel == nil, let currentUser = appState.currentUser { |
| 19 | let vm = HomeViewModel(currentUser: currentUser, client: appState.client) | 29 | let vm = HomeViewModel(currentUser: currentUser, client: appState.client) |
| @@ -26,16 +36,17 @@ struct HomeView: View { | |||
| 26 | @ViewBuilder | 36 | @ViewBuilder |
| 27 | private func content(_ viewModel: HomeViewModel) -> some View { | 37 | private func content(_ viewModel: HomeViewModel) -> some View { |
| 28 | List { | 38 | List { |
| 39 | projectsSection(viewModel) | ||
| 29 | assignedTicketsSection(viewModel) | 40 | assignedTicketsSection(viewModel) |
| 30 | recentBuildsSection(viewModel) | 41 | recentBuildsSection(viewModel) |
| 31 | } | 42 | } |
| 32 | .listStyle(.insetGrouped) | 43 | .listStyle(.insetGrouped) |
| 33 | .overlay { | 44 | .overlay { |
| 34 | if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && | 45 | if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && |
| 35 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { | 46 | viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { |
| 36 | SRHTLoadingStateView(message: "Loading Home…") | 47 | SRHTLoadingStateView(message: "Loading Home…") |
| 37 | } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && | 48 | } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && |
| 38 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | 49 | viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && |
| 39 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { | 50 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { |
| 40 | ContentUnavailableView( | 51 | ContentUnavailableView( |
| 41 | "All Clear", | 52 | "All Clear", |
| @@ -49,6 +60,25 @@ struct HomeView: View { | |||
| 49 | } | 60 | } |
| 50 | } | 61 | } |
| 51 | 62 | ||
| 63 | @ViewBuilder | ||
| 64 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { | ||
| 65 | if !viewModel.projects.isEmpty { | ||
| 66 | Section { | ||
| 67 | ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in | ||
| 68 | NavigationLink { | ||
| 69 | ProjectDetailView(project: project) | ||
| 70 | } label: { | ||
| 71 | HomeProjectRow(project: project) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } header: { | ||
| 75 | HomeSectionHeader("Projects") { | ||
| 76 | HomeProjectsListView(viewModel: viewModel) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 52 | @ViewBuilder | 82 | @ViewBuilder |
| 53 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { | 83 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { |
| 54 | Section { | 84 | Section { |
| @@ -123,6 +153,77 @@ struct HomeView: View { | |||
| 123 | 153 | ||
| 124 | } | 154 | } |
| 125 | 155 | ||
| 156 | private struct HomeInboxToolbarIcon: View { | ||
| 157 | let hasUnreadThreads: Bool | ||
| 158 | |||
| 159 | var body: some View { | ||
| 160 | ZStack(alignment: .topTrailing) { | ||
| 161 | Image(systemName: hasUnreadThreads ? "tray.fill" : "tray") | ||
| 162 | |||
| 163 | if hasUnreadThreads { | ||
| 164 | Circle() | ||
| 165 | .fill(.blue) | ||
| 166 | .frame(width: 9, height: 9) | ||
| 167 | .offset(x: 4, y: -2) | ||
| 168 | } | ||
| 169 | } | ||
| 170 | .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox") | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | private struct HomeProjectRow: View { | ||
| 175 | let project: Project | ||
| 176 | |||
| 177 | var body: some View { | ||
| 178 | VStack(alignment: .leading, spacing: 4) { | ||
| 179 | Text(project.name) | ||
| 180 | .font(.subheadline.weight(.medium)) | ||
| 181 | .lineLimit(1) | ||
| 182 | |||
| 183 | if let description = project.description, !description.isEmpty { | ||
| 184 | Text(description) | ||
| 185 | .font(.caption) | ||
| 186 | .foregroundStyle(.secondary) | ||
| 187 | .lineLimit(1) | ||
| 188 | } | ||
| 189 | |||
| 190 | if let summary = project.resourceSummary { | ||
| 191 | Text(summary) | ||
| 192 | .font(.caption) | ||
| 193 | .foregroundStyle(.tertiary) | ||
| 194 | .lineLimit(1) | ||
| 195 | } | ||
| 196 | } | ||
| 197 | .padding(.vertical, 2) | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | private struct HomeProjectsListView: View { | ||
| 202 | let viewModel: HomeViewModel | ||
| 203 | |||
| 204 | var body: some View { | ||
| 205 | List { | ||
| 206 | ForEach(viewModel.projects) { project in | ||
| 207 | NavigationLink { | ||
| 208 | ProjectDetailView(project: project) | ||
| 209 | } label: { | ||
| 210 | HomeProjectRow(project: project) | ||
| 211 | } | ||
| 212 | } | ||
| 213 | } | ||
| 214 | .navigationTitle("Projects") | ||
| 215 | .navigationBarTitleDisplayMode(.inline) | ||
| 216 | .refreshable { | ||
| 217 | await viewModel.loadDashboard() | ||
| 218 | } | ||
| 219 | .overlay { | ||
| 220 | if viewModel.isLoadingProjects && viewModel.projects.isEmpty { | ||
| 221 | SRHTLoadingStateView(message: "Loading projects…") | ||
| 222 | } | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 126 | private struct HomeBuildRow: View { | 227 | private struct HomeBuildRow: View { |
| 127 | let build: HomeBuildItem | 228 | let build: HomeBuildItem |
| 128 | 229 | ||
Hutch/Views/Home/HomeViewModel.swift +184
| @@ -33,6 +33,36 @@ private struct HomeTrackerTicketsPage: Decodable, Sendable { | |||
| 33 | let results: [HomeTicketPayload] | 33 | let results: [HomeTicketPayload] |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | private struct HomeInboxSubscriptionsResponse: Decodable, Sendable { | ||
| 37 | let subscriptions: HomeInboxSubscriptionPage | ||
| 38 | } | ||
| 39 | |||
| 40 | private struct HomeInboxSubscriptionPage: Decodable, Sendable { | ||
| 41 | let results: [HomeInboxSubscription] | ||
| 42 | let cursor: String? | ||
| 43 | } | ||
| 44 | |||
| 45 | private struct HomeInboxSubscription: Decodable, Sendable { | ||
| 46 | let list: InboxMailingListReference? | ||
| 47 | } | ||
| 48 | |||
| 49 | private struct HomeInboxListThreadsResponse: Decodable, Sendable { | ||
| 50 | let list: HomeInboxMailingListThreads | ||
| 51 | } | ||
| 52 | |||
| 53 | private struct HomeInboxMailingListThreads: Decodable, Sendable { | ||
| 54 | let threads: HomeInboxThreadPage | ||
| 55 | } | ||
| 56 | |||
| 57 | private struct HomeInboxThreadPage: Decodable, Sendable { | ||
| 58 | let results: [HomeInboxThreadPayload] | ||
| 59 | } | ||
| 60 | |||
| 61 | private struct HomeInboxThreadPayload: Decodable, Sendable { | ||
| 62 | let updated: Date | ||
| 63 | let subject: String | ||
| 64 | } | ||
| 65 | |||
| 36 | private struct HomeTicketPayload: Decodable, Sendable { | 66 | private struct HomeTicketPayload: Decodable, Sendable { |
| 37 | let id: Int | 67 | let id: Int |
| 38 | let title: String | 68 | let title: String |
| @@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { | |||
| 106 | @Observable | 136 | @Observable |
| 107 | @MainActor | 137 | @MainActor |
| 108 | final class HomeViewModel { | 138 | final class HomeViewModel { |
| 139 | private(set) var projects: [Project] = [] | ||
| 109 | private(set) var failedBuilds: [HomeBuildItem] = [] | 140 | private(set) var failedBuilds: [HomeBuildItem] = [] |
| 110 | private(set) var assignedTickets: [HomeAssignedTicket] = [] | 141 | private(set) var assignedTickets: [HomeAssignedTicket] = [] |
| 111 | private(set) var recentBuilds: [HomeBuildItem] = [] | 142 | private(set) var recentBuilds: [HomeBuildItem] = [] |
| 143 | private(set) var hasUnreadInboxThreads = false | ||
| 144 | private(set) var isLoadingProjects = false | ||
| 112 | private(set) var isLoadingFailedBuilds = false | 145 | private(set) var isLoadingFailedBuilds = false |
| 113 | private(set) var isLoadingAssignedTickets = false | 146 | private(set) var isLoadingAssignedTickets = false |
| 114 | private(set) var isLoadingRecentBuilds = false | 147 | private(set) var isLoadingRecentBuilds = false |
| @@ -118,7 +151,10 @@ final class HomeViewModel { | |||
| 118 | 151 | ||
| 119 | private let currentUser: User | 152 | private let currentUser: User |
| 120 | private let client: SRHTClient | 153 | private let client: SRHTClient |
| 154 | private let projectService: ProjectService | ||
| 121 | private let ticketFetchConcurrencyLimit = 6 | 155 | private let ticketFetchConcurrencyLimit = 6 |
| 156 | private let inboxUnreadConcurrencyLimit = 4 | ||
| 157 | private let inboxUnreadThreadPreviewLimit = 10 | ||
| 122 | 158 | ||
| 123 | private static let jobsQuery = """ | 159 | private static let jobsQuery = """ |
| 124 | query jobs { | 160 | query jobs { |
| @@ -177,12 +213,45 @@ final class HomeViewModel { | |||
| 177 | } | 213 | } |
| 178 | """ | 214 | """ |
| 179 | 215 | ||
| 216 | private static let inboxSubscriptionsQuery = """ | ||
| 217 | query inboxSubscriptions($cursor: Cursor) { | ||
| 218 | subscriptions(cursor: $cursor) { | ||
| 219 | results { | ||
| 220 | ... on MailingListSubscription { | ||
| 221 | list { | ||
| 222 | id | ||
| 223 | rid | ||
| 224 | name | ||
| 225 | owner { canonicalName } | ||
| 226 | } | ||
| 227 | } | ||
| 228 | } | ||
| 229 | cursor | ||
| 230 | } | ||
| 231 | } | ||
| 232 | """ | ||
| 233 | |||
| 234 | private static let inboxListThreadsQuery = """ | ||
| 235 | query inboxListThreads($rid: ID!) { | ||
| 236 | list(rid: $rid) { | ||
| 237 | threads { | ||
| 238 | results { | ||
| 239 | updated | ||
| 240 | subject | ||
| 241 | } | ||
| 242 | } | ||
| 243 | } | ||
| 244 | } | ||
| 245 | """ | ||
| 246 | |||
| 180 | init(currentUser: User, client: SRHTClient) { | 247 | init(currentUser: User, client: SRHTClient) { |
| 181 | self.currentUser = currentUser | 248 | self.currentUser = currentUser |
| 182 | self.client = client | 249 | self.client = client |
| 250 | self.projectService = ProjectService(client: client) | ||
| 183 | } | 251 | } |
| 184 | 252 | ||
| 185 | func loadDashboard() async { | 253 | func loadDashboard() async { |
| 254 | isLoadingProjects = true | ||
| 186 | isLoadingFailedBuilds = true | 255 | isLoadingFailedBuilds = true |
| 187 | isLoadingAssignedTickets = true | 256 | isLoadingAssignedTickets = true |
| 188 | isLoadingRecentBuilds = true | 257 | isLoadingRecentBuilds = true |
| @@ -190,8 +259,19 @@ final class HomeViewModel { | |||
| 190 | assignedTicketsError = nil | 259 | assignedTicketsError = nil |
| 191 | recentBuildsError = nil | 260 | recentBuildsError = nil |
| 192 | 261 | ||
| 262 | async let projectsTask = loadProjects() | ||
| 193 | async let jobsTask = loadRecentJobs() | 263 | async let jobsTask = loadRecentJobs() |
| 194 | async let assignedTicketsTask = loadAssignedTickets() | 264 | async let assignedTicketsTask = loadAssignedTickets() |
| 265 | async let inboxUnreadTask = loadInboxUnreadState() | ||
| 266 | |||
| 267 | let projectsResult = await projectsTask | ||
| 268 | switch projectsResult { | ||
| 269 | case .success(let projects): | ||
| 270 | self.projects = projects | ||
| 271 | case .failure: | ||
| 272 | self.projects = [] | ||
| 273 | } | ||
| 274 | isLoadingProjects = false | ||
| 195 | 275 | ||
| 196 | let recentJobsResult = await jobsTask | 276 | let recentJobsResult = await jobsTask |
| 197 | 277 | ||
| @@ -222,6 +302,16 @@ final class HomeViewModel { | |||
| 222 | self.assignedTicketsError = error.localizedDescription | 302 | self.assignedTicketsError = error.localizedDescription |
| 223 | } | 303 | } |
| 224 | isLoadingAssignedTickets = false | 304 | isLoadingAssignedTickets = false |
| 305 | |||
| 306 | hasUnreadInboxThreads = (await inboxUnreadTask) ?? false | ||
| 307 | } | ||
| 308 | |||
| 309 | private func loadProjects() async -> Result<[Project], Error> { | ||
| 310 | do { | ||
| 311 | return .success(try await projectService.fetchProjects()) | ||
| 312 | } catch { | ||
| 313 | return .failure(error) | ||
| 314 | } | ||
| 225 | } | 315 | } |
| 226 | 316 | ||
| 227 | private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { | 317 | private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { |
| @@ -237,6 +327,100 @@ final class HomeViewModel { | |||
| 237 | } | 327 | } |
| 238 | } | 328 | } |
| 239 | 329 | ||
| 330 | private func loadInboxUnreadState() async -> Bool? { | ||
| 331 | do { | ||
| 332 | return try await fetchHasUnreadInboxThreads() | ||
| 333 | } catch { | ||
| 334 | return nil | ||
| 335 | } | ||
| 336 | } | ||
| 337 | |||
| 338 | private func fetchHasUnreadInboxThreads() async throws -> Bool { | ||
| 339 | let mailingLists = try await fetchInboxMailingLists() | ||
| 340 | guard !mailingLists.isEmpty else { return false } | ||
| 341 | |||
| 342 | var startIndex = mailingLists.startIndex | ||
| 343 | while startIndex < mailingLists.endIndex { | ||
| 344 | let endIndex = mailingLists.index( | ||
| 345 | startIndex, | ||
| 346 | offsetBy: inboxUnreadConcurrencyLimit, | ||
| 347 | limitedBy: mailingLists.endIndex | ||
| 348 | ) ?? mailingLists.endIndex | ||
| 349 | let batch = Array(mailingLists[startIndex..<endIndex]) | ||
| 350 | |||
| 351 | let batchHasUnread = await withTaskGroup(of: Bool.self) { group in | ||
| 352 | for mailingList in batch { | ||
| 353 | group.addTask { | ||
| 354 | (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false | ||
| 355 | } | ||
| 356 | } | ||
| 357 | |||
| 358 | for await hasUnread in group { | ||
| 359 | if hasUnread { | ||
| 360 | group.cancelAll() | ||
| 361 | return true | ||
| 362 | } | ||
| 363 | } | ||
| 364 | return false | ||
| 365 | } | ||
| 366 | |||
| 367 | if batchHasUnread { | ||
| 368 | return true | ||
| 369 | } | ||
| 370 | |||
| 371 | startIndex = endIndex | ||
| 372 | } | ||
| 373 | |||
| 374 | return false | ||
| 375 | } | ||
| 376 | |||
| 377 | private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { | ||
| 378 | var subscriptions: [HomeInboxSubscription] = [] | ||
| 379 | var cursor: String? | ||
| 380 | |||
| 381 | while true { | ||
| 382 | var variables: [String: any Sendable] = [:] | ||
| 383 | if let cursor { | ||
| 384 | variables["cursor"] = cursor | ||
| 385 | } | ||
| 386 | |||
| 387 | let response = try await client.execute( | ||
| 388 | service: .lists, | ||
| 389 | query: Self.inboxSubscriptionsQuery, | ||
| 390 | variables: variables.isEmpty ? nil : variables, | ||
| 391 | responseType: HomeInboxSubscriptionsResponse.self | ||
| 392 | ) | ||
| 393 | |||
| 394 | subscriptions.append(contentsOf: response.subscriptions.results) | ||
| 395 | guard let nextCursor = response.subscriptions.cursor else { | ||
| 396 | break | ||
| 397 | } | ||
| 398 | cursor = nextCursor | ||
| 399 | } | ||
| 400 | |||
| 401 | var seen = Set<String>() | ||
| 402 | return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } | ||
| 403 | } | ||
| 404 | |||
| 405 | private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool { | ||
| 406 | let response = try await client.execute( | ||
| 407 | service: .lists, | ||
| 408 | query: Self.inboxListThreadsQuery, | ||
| 409 | variables: ["rid": mailingList.rid], | ||
| 410 | responseType: HomeInboxListThreadsResponse.self | ||
| 411 | ) | ||
| 412 | |||
| 413 | return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in | ||
| 414 | let normalizedSubject = thread.subject | ||
| 415 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | ||
| 416 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 417 | .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) | ||
| 418 | .lowercased() | ||
| 419 | let threadID = "\(mailingList.rid)#\(normalizedSubject)" | ||
| 420 | return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) | ||
| 421 | } | ||
| 422 | } | ||
| 423 | |||
| 240 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { | 424 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { |
| 241 | do { | 425 | do { |
| 242 | let trackers = try await fetchAllTrackers() | 426 | let trackers = try await fetchAllTrackers() |
Hutch/Views/Inbox/InboxView.swift +6 −9
| @@ -51,9 +51,9 @@ struct InboxView: View { | |||
| 51 | ) | 51 | ) |
| 52 | } else if viewModel.threads.isEmpty, viewModel.error == nil { | 52 | } else if viewModel.threads.isEmpty, viewModel.error == nil { |
| 53 | ContentUnavailableView( | 53 | ContentUnavailableView( |
| 54 | "No Threads", | 54 | "Inbox Zero", |
| 55 | systemImage: "tray", | 55 | systemImage: "tray", |
| 56 | description: Text("Patch threads will appear here.") | 56 | description: Text("Unread threads will appear here.") |
| 57 | ) | 57 | ) |
| 58 | } | 58 | } |
| 59 | } | 59 | } |
| @@ -75,19 +75,16 @@ struct InboxView: View { | |||
| 75 | private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { | 75 | private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { |
| 76 | Button { | 76 | Button { |
| 77 | withAnimation(.easeInOut(duration: 0.2)) { | 77 | withAnimation(.easeInOut(duration: 0.2)) { |
| 78 | viewModel.toggleThreadReadState(thread) | 78 | viewModel.markThreadRead(thread) |
| 79 | } | 79 | } |
| 80 | } label: { | 80 | } label: { |
| 81 | Label( | 81 | Label("Mark as Read", systemImage: "envelope.open") |
| 82 | thread.isUnread ? "Mark as Read" : "Mark as Unread", | ||
| 83 | systemImage: thread.isUnread ? "envelope.open" : "envelope.badge" | ||
| 84 | ) | ||
| 85 | } | 82 | } |
| 86 | .tint(thread.isUnread ? .blue : .gray) | 83 | .tint(.blue) |
| 87 | } | 84 | } |
| 88 | } | 85 | } |
| 89 | 86 | ||
| 90 | private struct InboxThreadRow: View { | 87 | struct InboxThreadRow: View { |
| 91 | let thread: InboxThreadSummary | 88 | let thread: InboxThreadSummary |
| 92 | 89 | ||
| 93 | var body: some View { | 90 | var body: some View { |
Hutch/Views/Inbox/InboxViewModel.swift +8 −2
| @@ -127,7 +127,9 @@ final class InboxViewModel { | |||
| 127 | let subscriptions = try await fetchSubscriptions() | 127 | let subscriptions = try await fetchSubscriptions() |
| 128 | let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) | 128 | let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) |
| 129 | let fetchedThreads = try await fetchThreads(for: mailingLists) | 129 | let fetchedThreads = try await fetchThreads(for: mailingLists) |
| 130 | threads = fetchedThreads.sorted { lhs, rhs in | 130 | threads = fetchedThreads |
| 131 | .filter(\.isUnread) | ||
| 132 | .sorted { lhs, rhs in | ||
| 131 | if lhs.lastActivityAt == rhs.lastActivityAt { | 133 | if lhs.lastActivityAt == rhs.lastActivityAt { |
| 132 | return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending | 134 | return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending |
| 133 | } | 135 | } |
| @@ -145,7 +147,7 @@ final class InboxViewModel { | |||
| 145 | inboxListLogger.debug( | 147 | inboxListLogger.debug( |
| 146 | "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)" | 148 | "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)" |
| 147 | ) | 149 | ) |
| 148 | updateThread(thread, isUnread: false) | 150 | threads.removeAll { $0.id == thread.id } |
| 149 | } | 151 | } |
| 150 | 152 | ||
| 151 | func markThreadUnread(_ thread: InboxThreadSummary) { | 153 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| @@ -329,6 +331,10 @@ final class InboxViewModel { | |||
| 329 | private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { | 331 | private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { |
| 330 | guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } | 332 | guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } |
| 331 | let current = threads[index] | 333 | let current = threads[index] |
| 334 | if !isUnread { | ||
| 335 | threads.remove(at: index) | ||
| 336 | return | ||
| 337 | } | ||
| 332 | threads[index] = InboxThreadSummary( | 338 | threads[index] = InboxThreadSummary( |
| 333 | rootEmailID: current.rootEmailID, | 339 | rootEmailID: current.rootEmailID, |
| 334 | rootMessageID: current.rootMessageID, | 340 | rootMessageID: current.rootMessageID, |
Hutch/Views/Inbox/ThreadDetailView.swift +51 −9
| @@ -8,10 +8,29 @@ private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: | |||
| 8 | struct ThreadDetailView: View { | 8 | struct ThreadDetailView: View { |
| 9 | let thread: InboxThreadSummary | 9 | let thread: InboxThreadSummary |
| 10 | let onViewed: () -> Void | 10 | let onViewed: () -> Void |
| 11 | var onMarkRead: (() -> Void)? = nil | ||
| 12 | var onMarkUnread: (() -> Void)? = nil | ||
| 11 | 13 | ||
| 12 | @Environment(AppState.self) private var appState | 14 | @Environment(AppState.self) private var appState |
| 13 | @State private var viewModel: ThreadViewModel? | 15 | @State private var viewModel: ThreadViewModel? |
| 14 | @State private var replySuccessMessage: String? | 16 | @State private var replySuccessMessage: String? |
| 17 | @State private var loadedThreadID: String? | ||
| 18 | @State private var hasMarkedCurrentThreadViewed = false | ||
| 19 | @State private var suppressAutoMarkViewed = false | ||
| 20 | @State private var isUnread: Bool | ||
| 21 | |||
| 22 | init( | ||
| 23 | thread: InboxThreadSummary, | ||
| 24 | onViewed: @escaping () -> Void, | ||
| 25 | onMarkRead: (() -> Void)? = nil, | ||
| 26 | onMarkUnread: (() -> Void)? = nil | ||
| 27 | ) { | ||
| 28 | self.thread = thread | ||
| 29 | self.onViewed = onViewed | ||
| 30 | self.onMarkRead = onMarkRead | ||
| 31 | self.onMarkUnread = onMarkUnread | ||
| 32 | self._isUnread = State(initialValue: thread.isUnread) | ||
| 33 | } | ||
| 15 | 34 | ||
| 16 | var body: some View { | 35 | var body: some View { |
| 17 | Group { | 36 | Group { |
| @@ -23,13 +42,21 @@ struct ThreadDetailView: View { | |||
| 23 | } | 42 | } |
| 24 | .navigationTitle("Thread") | 43 | .navigationTitle("Thread") |
| 25 | .navigationBarTitleDisplayMode(.inline) | 44 | .navigationBarTitleDisplayMode(.inline) |
| 26 | .task { | 45 | .task(id: thread.id) { |
| 27 | if viewModel == nil { | 46 | guard loadedThreadID != thread.id else { return } |
| 28 | onViewed() | 47 | let vm = ThreadViewModel(summary: thread, client: appState.client) |
| 29 | let vm = ThreadViewModel(summary: thread, client: appState.client) | 48 | viewModel = vm |
| 30 | viewModel = vm | 49 | loadedThreadID = thread.id |
| 31 | await vm.loadThread() | 50 | hasMarkedCurrentThreadViewed = false |
| 32 | } | 51 | suppressAutoMarkViewed = false |
| 52 | isUnread = thread.isUnread | ||
| 53 | await vm.loadThread() | ||
| 54 | } | ||
| 55 | .onChange(of: viewModel?.thread?.id) { _, threadID in | ||
| 56 | guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return } | ||
| 57 | hasMarkedCurrentThreadViewed = true | ||
| 58 | isUnread = false | ||
| 59 | onViewed() | ||
| 33 | } | 60 | } |
| 34 | .sheet(item: Binding( | 61 | .sheet(item: Binding( |
| 35 | get: { viewModel?.composeDraft }, | 62 | get: { viewModel?.composeDraft }, |
| @@ -109,8 +136,23 @@ struct ThreadDetailView: View { | |||
| 109 | .listStyle(.plain) | 136 | .listStyle(.plain) |
| 110 | .toolbar { | 137 | .toolbar { |
| 111 | ToolbarItem(placement: .topBarTrailing) { | 138 | ToolbarItem(placement: .topBarTrailing) { |
| 112 | Button("Reply") { | 139 | HStack { |
| 113 | viewModel.prepareReply() | 140 | if onMarkRead != nil || onMarkUnread != nil { |
| 141 | Button(isUnread ? "Mark Read" : "Mark Unread") { | ||
| 142 | suppressAutoMarkViewed = !isUnread | ||
| 143 | if isUnread { | ||
| 144 | onMarkRead?() | ||
| 145 | isUnread = false | ||
| 146 | } else { | ||
| 147 | onMarkUnread?() | ||
| 148 | isUnread = true | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | Button("Reply") { | ||
| 154 | viewModel.prepareReply() | ||
| 155 | } | ||
| 114 | } | 156 | } |
| 115 | } | 157 | } |
| 116 | } | 158 | } |
Hutch/Views/Lists/MailingListListView.swift added +159
| @@ -0,0 +1,159 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class MailingListListViewModel { | ||
| 6 | private(set) var mailingLists: [InboxMailingListReference] = [] | ||
| 7 | private(set) var isLoading = false | ||
| 8 | var error: String? | ||
| 9 | |||
| 10 | private let client: SRHTClient | ||
| 11 | |||
| 12 | private static let subscriptionsQuery = """ | ||
| 13 | query mailingLists($cursor: Cursor) { | ||
| 14 | subscriptions(cursor: $cursor) { | ||
| 15 | results { | ||
| 16 | ... on MailingListSubscription { | ||
| 17 | list { | ||
| 18 | id | ||
| 19 | rid | ||
| 20 | name | ||
| 21 | owner { canonicalName } | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | cursor | ||
| 26 | } | ||
| 27 | } | ||
| 28 | """ | ||
| 29 | |||
| 30 | init(client: SRHTClient) { | ||
| 31 | self.client = client | ||
| 32 | } | ||
| 33 | |||
| 34 | func loadMailingLists() async { | ||
| 35 | guard !isLoading else { return } | ||
| 36 | isLoading = true | ||
| 37 | error = nil | ||
| 38 | defer { isLoading = false } | ||
| 39 | |||
| 40 | do { | ||
| 41 | mailingLists = try await fetchMailingLists() | ||
| 42 | } catch { | ||
| 43 | self.error = "Failed to load mailing lists" | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | private func fetchMailingLists() async throws -> [InboxMailingListReference] { | ||
| 48 | struct Response: Decodable, Sendable { | ||
| 49 | let subscriptions: Page | ||
| 50 | } | ||
| 51 | |||
| 52 | struct Page: Decodable, Sendable { | ||
| 53 | let results: [Subscription] | ||
| 54 | let cursor: String? | ||
| 55 | } | ||
| 56 | |||
| 57 | struct Subscription: Decodable, Sendable { | ||
| 58 | let list: InboxMailingListReference? | ||
| 59 | } | ||
| 60 | |||
| 61 | var results: [InboxMailingListReference] = [] | ||
| 62 | var cursor: String? | ||
| 63 | |||
| 64 | while true { | ||
| 65 | var variables: [String: any Sendable] = [:] | ||
| 66 | if let cursor { | ||
| 67 | variables["cursor"] = cursor | ||
| 68 | } | ||
| 69 | |||
| 70 | let response = try await client.execute( | ||
| 71 | service: .lists, | ||
| 72 | query: Self.subscriptionsQuery, | ||
| 73 | variables: variables.isEmpty ? nil : variables, | ||
| 74 | responseType: Response.self | ||
| 75 | ) | ||
| 76 | |||
| 77 | results.append(contentsOf: response.subscriptions.results.compactMap(\.list)) | ||
| 78 | guard let nextCursor = response.subscriptions.cursor else { | ||
| 79 | break | ||
| 80 | } | ||
| 81 | cursor = nextCursor | ||
| 82 | } | ||
| 83 | |||
| 84 | var seen = Set<String>() | ||
| 85 | return results | ||
| 86 | .filter { seen.insert($0.rid).inserted } | ||
| 87 | .sorted { | ||
| 88 | if $0.owner.canonicalName == $1.owner.canonicalName { | ||
| 89 | return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending | ||
| 90 | } | ||
| 91 | return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | struct MailingListListView: View { | ||
| 97 | @Environment(AppState.self) private var appState | ||
| 98 | @State private var viewModel: MailingListListViewModel? | ||
| 99 | |||
| 100 | var body: some View { | ||
| 101 | Group { | ||
| 102 | if let viewModel { | ||
| 103 | content(viewModel) | ||
| 104 | } else { | ||
| 105 | SRHTLoadingStateView(message: "Loading mailing lists…") | ||
| 106 | } | ||
| 107 | } | ||
| 108 | .navigationTitle("Lists") | ||
| 109 | .task { | ||
| 110 | if viewModel == nil { | ||
| 111 | let vm = MailingListListViewModel(client: appState.client) | ||
| 112 | viewModel = vm | ||
| 113 | await vm.loadMailingLists() | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | @ViewBuilder | ||
| 119 | private func content(_ viewModel: MailingListListViewModel) -> some View { | ||
| 120 | @Bindable var vm = viewModel | ||
| 121 | |||
| 122 | List { | ||
| 123 | ForEach(viewModel.mailingLists, id: \.rid) { mailingList in | ||
| 124 | NavigationLink(value: MoreRoute.mailingList(mailingList)) { | ||
| 125 | VStack(alignment: .leading, spacing: 4) { | ||
| 126 | Text(mailingList.name) | ||
| 127 | .font(.subheadline.weight(.medium)) | ||
| 128 | Text(mailingList.owner.canonicalName) | ||
| 129 | .font(.caption) | ||
| 130 | .foregroundStyle(.secondary) | ||
| 131 | } | ||
| 132 | .padding(.vertical, 2) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | } | ||
| 136 | .listStyle(.plain) | ||
| 137 | .overlay { | ||
| 138 | if viewModel.isLoading, viewModel.mailingLists.isEmpty { | ||
| 139 | SRHTLoadingStateView(message: "Loading mailing lists…") | ||
| 140 | } else if let error = viewModel.error, viewModel.mailingLists.isEmpty { | ||
| 141 | SRHTErrorStateView( | ||
| 142 | title: "Couldn't Load Mailing Lists", | ||
| 143 | message: error, | ||
| 144 | retryAction: { await viewModel.loadMailingLists() } | ||
| 145 | ) | ||
| 146 | } else if viewModel.mailingLists.isEmpty { | ||
| 147 | ContentUnavailableView( | ||
| 148 | "No Mailing Lists", | ||
| 149 | systemImage: "list.bullet.rectangle", | ||
| 150 | description: Text("Your subscribed mailing lists will appear here.") | ||
| 151 | ) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | .srhtErrorBanner(error: $vm.error) | ||
| 155 | .refreshable { | ||
| 156 | await viewModel.loadMailingLists() | ||
| 157 | } | ||
| 158 | } | ||
| 159 | } | ||
Hutch/Views/More/MoreView.swift added +40
| @@ -0,0 +1,40 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct MoreView: View { | ||
| 4 | private let unsupportedLinks: [(title: String, url: URL)] = [ | ||
| 5 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), | ||
| 6 | ("man.sr.ht", URL(string: "https://man.sr.ht")!), | ||
| 7 | ("srht.site", URL(string: "https://srht.site")!) | ||
| 8 | ] | ||
| 9 | |||
| 10 | var body: some View { | ||
| 11 | List { | ||
| 12 | Section { | ||
| 13 | NavigationLink(value: MoreRoute.lists) { | ||
| 14 | Label("Lists", systemImage: "list.bullet.rectangle") | ||
| 15 | } | ||
| 16 | |||
| 17 | NavigationLink(value: MoreRoute.pastes) { | ||
| 18 | Label("Pastes", systemImage: "doc.on.clipboard") | ||
| 19 | } | ||
| 20 | |||
| 21 | NavigationLink(value: MoreRoute.settings) { | ||
| 22 | Label("Settings", systemImage: "gear") | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | Section { | ||
| 27 | ForEach(unsupportedLinks, id: \.title) { item in | ||
| 28 | Link(destination: item.url) { | ||
| 29 | Label(item.title, systemImage: "safari") | ||
| 30 | } | ||
| 31 | } | ||
| 32 | } header: { | ||
| 33 | Text("External Links") | ||
| 34 | } footer: { | ||
| 35 | Text("These SourceHut services are not supported in-app and open in your browser.") | ||
| 36 | } | ||
| 37 | } | ||
| 38 | .navigationTitle("More") | ||
| 39 | } | ||
| 40 | } | ||
Hutch/Views/Pastes/PasteDetailView.swift added +288
| @@ -0,0 +1,288 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct PasteDetailView: View { | ||
| 4 | let paste: Paste | ||
| 5 | var onUpdated: ((Paste) -> Void)? = nil | ||
| 6 | var onDeleted: ((String) -> Void)? = nil | ||
| 7 | |||
| 8 | @Environment(AppState.self) private var appState | ||
| 9 | @Environment(\.dismiss) private var dismiss | ||
| 10 | @State private var viewModel: PasteDetailViewModel? | ||
| 11 | @State private var showVisibilitySheet = false | ||
| 12 | @State private var showDeleteConfirmation = false | ||
| 13 | |||
| 14 | var body: some View { | ||
| 15 | Group { | ||
| 16 | if let viewModel { | ||
| 17 | content(viewModel) | ||
| 18 | } else { | ||
| 19 | SRHTLoadingStateView(message: "Loading paste…") | ||
| 20 | } | ||
| 21 | } | ||
| 22 | .navigationTitle(displayTitle) | ||
| 23 | .navigationBarTitleDisplayMode(.inline) | ||
| 24 | .toolbar { | ||
| 25 | ToolbarItemGroup(placement: .topBarTrailing) { | ||
| 26 | SRHTShareButton( | ||
| 27 | url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) }, | ||
| 28 | target: .paste | ||
| 29 | ) { | ||
| 30 | Image(systemName: "square.and.arrow.up") | ||
| 31 | } | ||
| 32 | |||
| 33 | if viewModel != nil { | ||
| 34 | Menu { | ||
| 35 | Button { | ||
| 36 | showVisibilitySheet = true | ||
| 37 | } label: { | ||
| 38 | Label("Change Visibility", systemImage: "eye") | ||
| 39 | } | ||
| 40 | |||
| 41 | Button(role: .destructive) { | ||
| 42 | showDeleteConfirmation = true | ||
| 43 | } label: { | ||
| 44 | Label("Delete Paste", systemImage: "trash") | ||
| 45 | } | ||
| 46 | } label: { | ||
| 47 | Image(systemName: "ellipsis.circle") | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | .sheet(isPresented: $showVisibilitySheet) { | ||
| 53 | if let viewModel, let currentPaste { | ||
| 54 | PasteVisibilitySheet( | ||
| 55 | currentVisibility: currentPaste.visibility, | ||
| 56 | isUpdating: viewModel.isUpdatingVisibility | ||
| 57 | ) { visibility in | ||
| 58 | if let updated = await viewModel.updateVisibility(visibility) { | ||
| 59 | onUpdated?(updated) | ||
| 60 | showVisibilitySheet = false | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | .alert("Delete Paste?", isPresented: $showDeleteConfirmation) { | ||
| 66 | Button("Cancel", role: .cancel) {} | ||
| 67 | Button("Delete", role: .destructive) { | ||
| 68 | Task { | ||
| 69 | if await viewModel?.deletePaste() == true { | ||
| 70 | onDeleted?(paste.id) | ||
| 71 | dismiss() | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } message: { | ||
| 76 | Text("This paste will be permanently removed.") | ||
| 77 | } | ||
| 78 | .task { | ||
| 79 | if viewModel == nil { | ||
| 80 | let vm = PasteDetailViewModel( | ||
| 81 | pasteID: paste.id, | ||
| 82 | initialPaste: paste, | ||
| 83 | service: PasteService(client: appState.client) | ||
| 84 | ) | ||
| 85 | viewModel = vm | ||
| 86 | await vm.loadPaste() | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | private var currentPaste: Paste? { | ||
| 92 | viewModel?.paste ?? paste | ||
| 93 | } | ||
| 94 | |||
| 95 | private var displayTitle: String { | ||
| 96 | if let filename = currentPaste?.files.first?.filename, !filename.isEmpty { | ||
| 97 | return filename | ||
| 98 | } | ||
| 99 | return "Paste \(paste.id)" | ||
| 100 | } | ||
| 101 | |||
| 102 | @ViewBuilder | ||
| 103 | private func content(_ viewModel: PasteDetailViewModel) -> some View { | ||
| 104 | @Bindable var vm = viewModel | ||
| 105 | |||
| 106 | if viewModel.isLoading, viewModel.paste == nil { | ||
| 107 | SRHTLoadingStateView(message: "Loading paste…") | ||
| 108 | } else if let error = viewModel.error, viewModel.paste == nil { | ||
| 109 | SRHTErrorStateView( | ||
| 110 | title: "Couldn't Load Paste", | ||
| 111 | message: error, | ||
| 112 | retryAction: { await viewModel.loadPaste() } | ||
| 113 | ) | ||
| 114 | } else if let paste = viewModel.paste { | ||
| 115 | List { | ||
| 116 | Section("Details") { | ||
| 117 | LabeledContent("ID", value: paste.id) | ||
| 118 | LabeledContent("Owner", value: paste.user.canonicalName) | ||
| 119 | LabeledContent("Created", value: paste.created.relativeDescription) | ||
| 120 | LabeledContent("Visibility", value: visibilityLabel(paste.visibility)) | ||
| 121 | LabeledContent("Files", value: "\(paste.files.count)") | ||
| 122 | } | ||
| 123 | |||
| 124 | if paste.files.count > 1 { | ||
| 125 | Section("Files") { | ||
| 126 | Picker("Selected File", selection: Binding( | ||
| 127 | get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" }, | ||
| 128 | set: { viewModel.selectFile(hash: $0) } | ||
| 129 | )) { | ||
| 130 | ForEach(paste.files) { file in | ||
| 131 | Text(file.filename ?? String(file.hash.prefix(8))) | ||
| 132 | .tag(file.hash) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | if let file = viewModel.selectedFile { | ||
| 139 | Section("Current File") { | ||
| 140 | if let filename = file.filename, !filename.isEmpty { | ||
| 141 | LabeledContent("Filename", value: filename) | ||
| 142 | } | ||
| 143 | LabeledContent("Hash", value: file.hash) | ||
| 144 | } | ||
| 145 | |||
| 146 | Section { | ||
| 147 | if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil { | ||
| 148 | SRHTLoadingStateView(message: "Loading paste contents…") | ||
| 149 | .frame(minHeight: 180) | ||
| 150 | } else if let contents = viewModel.selectedFileContents { | ||
| 151 | PasteCodeBlock(text: contents) | ||
| 152 | } else { | ||
| 153 | Text("This file’s contents are unavailable.") | ||
| 154 | .foregroundStyle(.secondary) | ||
| 155 | } | ||
| 156 | } header: { | ||
| 157 | Text("Contents") | ||
| 158 | } | ||
| 159 | } | ||
| 160 | } | ||
| 161 | .listStyle(.insetGrouped) | ||
| 162 | .srhtErrorBanner(error: $vm.error) | ||
| 163 | .refreshable { | ||
| 164 | await viewModel.loadPaste() | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | private func visibilityLabel(_ visibility: Visibility) -> String { | ||
| 170 | switch visibility { | ||
| 171 | case .public: | ||
| 172 | return "Public" | ||
| 173 | case .unlisted: | ||
| 174 | return "Unlisted" | ||
| 175 | case .private: | ||
| 176 | return "Private" | ||
| 177 | } | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | private struct PasteCodeBlock: View { | ||
| 182 | let text: String | ||
| 183 | |||
| 184 | var body: some View { | ||
| 185 | ScrollView([.horizontal, .vertical], showsIndicators: true) { | ||
| 186 | Text(text.isEmpty ? " " : text) | ||
| 187 | .font(.system(.body, design: .monospaced)) | ||
| 188 | .textSelection(.enabled) | ||
| 189 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 190 | .padding(.vertical, 4) | ||
| 191 | } | ||
| 192 | .frame(minHeight: 220) | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | private struct PasteVisibilitySheet: View { | ||
| 197 | let currentVisibility: Visibility | ||
| 198 | let isUpdating: Bool | ||
| 199 | let onSave: (Visibility) async -> Void | ||
| 200 | |||
| 201 | @Environment(\.dismiss) private var dismiss | ||
| 202 | @State private var visibility: Visibility | ||
| 203 | |||
| 204 | init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) { | ||
| 205 | self.currentVisibility = currentVisibility | ||
| 206 | self.isUpdating = isUpdating | ||
| 207 | self.onSave = onSave | ||
| 208 | _visibility = State(initialValue: currentVisibility) | ||
| 209 | } | ||
| 210 | |||
| 211 | var body: some View { | ||
| 212 | NavigationStack { | ||
| 213 | List { | ||
| 214 | ForEach(visibilityOptions, id: \.self) { option in | ||
| 215 | Button { | ||
| 216 | visibility = option | ||
| 217 | } label: { | ||
| 218 | HStack { | ||
| 219 | VStack(alignment: .leading, spacing: 2) { | ||
| 220 | Text(title(for: option)) | ||
| 221 | .foregroundStyle(.primary) | ||
| 222 | Text(description(for: option)) | ||
| 223 | .font(.caption) | ||
| 224 | .foregroundStyle(.secondary) | ||
| 225 | } | ||
| 226 | |||
| 227 | Spacer() | ||
| 228 | |||
| 229 | if visibility == option { | ||
| 230 | Image(systemName: "checkmark") | ||
| 231 | .foregroundStyle(.tint) | ||
| 232 | } | ||
| 233 | } | ||
| 234 | .contentShape(Rectangle()) | ||
| 235 | } | ||
| 236 | .buttonStyle(.plain) | ||
| 237 | } | ||
| 238 | } | ||
| 239 | .listStyle(.insetGrouped) | ||
| 240 | .navigationTitle("Visibility") | ||
| 241 | .navigationBarTitleDisplayMode(.inline) | ||
| 242 | .toolbar { | ||
| 243 | ToolbarItem(placement: .cancellationAction) { | ||
| 244 | Button("Cancel") { dismiss() } | ||
| 245 | } | ||
| 246 | ToolbarItem(placement: .confirmationAction) { | ||
| 247 | Button("Save") { | ||
| 248 | Task { | ||
| 249 | await onSave(visibility) | ||
| 250 | } | ||
| 251 | } | ||
| 252 | .disabled(isUpdating || visibility == currentVisibility) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | .overlay { | ||
| 256 | if isUpdating { | ||
| 257 | ProgressView() | ||
| 258 | } | ||
| 259 | } | ||
| 260 | } | ||
| 261 | } | ||
| 262 | |||
| 263 | private var visibilityOptions: [Visibility] { | ||
| 264 | [.public, .unlisted, .private] | ||
| 265 | } | ||
| 266 | |||
| 267 | private func title(for visibility: Visibility) -> String { | ||
| 268 | switch visibility { | ||
| 269 | case .public: | ||
| 270 | "Public" | ||
| 271 | case .unlisted: | ||
| 272 | "Unlisted" | ||
| 273 | case .private: | ||
| 274 | "Private" | ||
| 275 | } | ||
| 276 | } | ||
| 277 | |||
| 278 | private func description(for visibility: Visibility) -> String { | ||
| 279 | switch visibility { | ||
| 280 | case .public: | ||
| 281 | "Visible to everyone and listed on your profile." | ||
| 282 | case .unlisted: | ||
| 283 | "Visible to anyone with the URL, but not listed on your profile." | ||
| 284 | case .private: | ||
| 285 | "Visible only to explicitly allowed viewers." | ||
| 286 | } | ||
| 287 | } | ||
| 288 | } | ||
Hutch/Views/Pastes/PasteDetailViewModel.swift added +114
| @@ -0,0 +1,114 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class PasteDetailViewModel { | ||
| 6 | private(set) var paste: Paste? | ||
| 7 | private(set) var isLoading = false | ||
| 8 | private(set) var isUpdatingVisibility = false | ||
| 9 | private(set) var isDeleting = false | ||
| 10 | private(set) var loadingFileHashes: Set<String> = [] | ||
| 11 | var error: String? | ||
| 12 | |||
| 13 | var selectedFileHash: String? | ||
| 14 | private(set) var fileContents: [String: String] = [:] | ||
| 15 | |||
| 16 | private let pasteID: String | ||
| 17 | private let service: PasteService | ||
| 18 | |||
| 19 | init(pasteID: String, initialPaste: Paste? = nil, service: PasteService) { | ||
| 20 | self.pasteID = pasteID | ||
| 21 | self.paste = initialPaste | ||
| 22 | self.service = service | ||
| 23 | self.selectedFileHash = initialPaste?.files.first?.hash | ||
| 24 | } | ||
| 25 | |||
| 26 | var selectedFile: PasteFile? { | ||
| 27 | let hash = selectedFileHash ?? paste?.files.first?.hash | ||
| 28 | return paste?.files.first(where: { $0.hash == hash }) | ||
| 29 | } | ||
| 30 | |||
| 31 | var selectedFileContents: String? { | ||
| 32 | guard let selectedFile else { return nil } | ||
| 33 | return fileContents[selectedFile.hash] | ||
| 34 | } | ||
| 35 | |||
| 36 | func loadPaste() async { | ||
| 37 | guard !isLoading else { return } | ||
| 38 | isLoading = true | ||
| 39 | error = nil | ||
| 40 | defer { isLoading = false } | ||
| 41 | |||
| 42 | do { | ||
| 43 | let loaded = try await service.loadPaste(id: pasteID) | ||
| 44 | paste = loaded | ||
| 45 | if selectedFileHash == nil { | ||
| 46 | selectedFileHash = loaded?.files.first?.hash | ||
| 47 | } | ||
| 48 | await loadSelectedFileContentsIfNeeded() | ||
| 49 | } catch { | ||
| 50 | self.error = error.localizedDescription | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | func selectFile(hash: String) { | ||
| 55 | selectedFileHash = hash | ||
| 56 | Task { | ||
| 57 | await loadSelectedFileContentsIfNeeded() | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | func updateVisibility(_ visibility: Visibility) async -> Paste? { | ||
| 62 | guard !isUpdatingVisibility else { return nil } | ||
| 63 | guard let paste else { return nil } | ||
| 64 | guard paste.visibility != visibility else { return paste } | ||
| 65 | |||
| 66 | isUpdatingVisibility = true | ||
| 67 | error = nil | ||
| 68 | defer { isUpdatingVisibility = false } | ||
| 69 | |||
| 70 | do { | ||
| 71 | let updatedPaste = try await service.updateVisibility(id: paste.id, visibility: visibility) | ||
| 72 | if let updatedPaste { | ||
| 73 | self.paste = updatedPaste | ||
| 74 | if selectedFileHash == nil { | ||
| 75 | selectedFileHash = updatedPaste.files.first?.hash | ||
| 76 | } | ||
| 77 | } | ||
| 78 | return updatedPaste | ||
| 79 | } catch { | ||
| 80 | self.error = error.localizedDescription | ||
| 81 | return nil | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | func deletePaste() async -> Bool { | ||
| 86 | guard !isDeleting else { return false } | ||
| 87 | isDeleting = true | ||
| 88 | error = nil | ||
| 89 | defer { isDeleting = false } | ||
| 90 | |||
| 91 | do { | ||
| 92 | _ = try await service.deletePaste(id: pasteID) | ||
| 93 | return true | ||
| 94 | } catch { | ||
| 95 | self.error = error.localizedDescription | ||
| 96 | return false | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | func loadSelectedFileContentsIfNeeded() async { | ||
| 101 | guard let file = selectedFile, fileContents[file.hash] == nil else { return } | ||
| 102 | guard let url = file.contents else { return } | ||
| 103 | guard !loadingFileHashes.contains(file.hash) else { return } | ||
| 104 | |||
| 105 | loadingFileHashes.insert(file.hash) | ||
| 106 | defer { loadingFileHashes.remove(file.hash) } | ||
| 107 | |||
| 108 | do { | ||
| 109 | fileContents[file.hash] = try await service.loadContents(from: url) | ||
| 110 | } catch { | ||
| 111 | self.error = error.localizedDescription | ||
| 112 | } | ||
| 113 | } | ||
| 114 | } | ||
Hutch/Views/Pastes/PasteListView.swift added +280
| @@ -0,0 +1,280 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct PasteListView: View { | ||
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @State private var viewModel: PasteListViewModel? | ||
| 6 | @State private var showCreatePasteSheet = false | ||
| 7 | @State private var createdPaste: Paste? | ||
| 8 | |||
| 9 | var body: some View { | ||
| 10 | Group { | ||
| 11 | if let viewModel { | ||
| 12 | content(viewModel) | ||
| 13 | } else { | ||
| 14 | SRHTLoadingStateView(message: "Loading pastes…") | ||
| 15 | } | ||
| 16 | } | ||
| 17 | .navigationTitle("Pastes") | ||
| 18 | .toolbar { | ||
| 19 | if viewModel != nil { | ||
| 20 | ToolbarItem(placement: .topBarTrailing) { | ||
| 21 | Button { | ||
| 22 | showCreatePasteSheet = true | ||
| 23 | } label: { | ||
| 24 | Image(systemName: "plus") | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | .sheet(isPresented: $showCreatePasteSheet) { | ||
| 30 | if let viewModel { | ||
| 31 | CreatePasteSheet(viewModel: viewModel) { paste in | ||
| 32 | showCreatePasteSheet = false | ||
| 33 | createdPaste = paste | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | .navigationDestination(isPresented: Binding( | ||
| 38 | get: { createdPaste != nil }, | ||
| 39 | set: { isPresented in | ||
| 40 | if !isPresented { | ||
| 41 | createdPaste = nil | ||
| 42 | } | ||
| 43 | } | ||
| 44 | )) { | ||
| 45 | if let createdPaste { | ||
| 46 | PasteDetailView( | ||
| 47 | paste: createdPaste, | ||
| 48 | onUpdated: { updated in | ||
| 49 | viewModel?.upsertPaste(updated) | ||
| 50 | }, | ||
| 51 | onDeleted: { id in | ||
| 52 | viewModel?.removePaste(id: id) | ||
| 53 | } | ||
| 54 | ) | ||
| 55 | } | ||
| 56 | } | ||
| 57 | .task { | ||
| 58 | if viewModel == nil { | ||
| 59 | let vm = PasteListViewModel(service: PasteService(client: appState.client)) | ||
| 60 | viewModel = vm | ||
| 61 | await vm.loadPastes() | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | @ViewBuilder | ||
| 67 | private func content(_ viewModel: PasteListViewModel) -> some View { | ||
| 68 | @Bindable var vm = viewModel | ||
| 69 | |||
| 70 | List { | ||
| 71 | ForEach(viewModel.pastes) { paste in | ||
| 72 | NavigationLink(value: paste) { | ||
| 73 | PasteRowView(paste: paste) | ||
| 74 | } | ||
| 75 | .task { | ||
| 76 | await viewModel.loadMoreIfNeeded(currentItem: paste) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | if viewModel.isLoadingMore { | ||
| 81 | HStack { | ||
| 82 | Spacer() | ||
| 83 | ProgressView() | ||
| 84 | Spacer() | ||
| 85 | } | ||
| 86 | .listRowSeparator(.hidden) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | .listStyle(.plain) | ||
| 90 | .overlay { | ||
| 91 | if viewModel.isLoading, viewModel.pastes.isEmpty { | ||
| 92 | SRHTLoadingStateView(message: "Loading pastes…") | ||
| 93 | } else if let error = viewModel.error, viewModel.pastes.isEmpty { | ||
| 94 | SRHTErrorStateView( | ||
| 95 | title: "Couldn't Load Pastes", | ||
| 96 | message: error, | ||
| 97 | retryAction: { await viewModel.loadPastes() } | ||
| 98 | ) | ||
| 99 | } else if viewModel.pastes.isEmpty { | ||
| 100 | ContentUnavailableView( | ||
| 101 | "No Pastes", | ||
| 102 | systemImage: "doc.on.clipboard", | ||
| 103 | description: Text("Your pastes will appear here.") | ||
| 104 | ) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) { | ||
| 108 | await viewModel.loadPastes() | ||
| 109 | } | ||
| 110 | .srhtErrorBanner(error: $vm.error) | ||
| 111 | .refreshable { | ||
| 112 | await viewModel.loadPastes() | ||
| 113 | } | ||
| 114 | .navigationDestination(for: Paste.self) { paste in | ||
| 115 | PasteDetailView( | ||
| 116 | paste: paste, | ||
| 117 | onUpdated: { updated in | ||
| 118 | viewModel.upsertPaste(updated) | ||
| 119 | }, | ||
| 120 | onDeleted: { id in | ||
| 121 | viewModel.removePaste(id: id) | ||
| 122 | } | ||
| 123 | ) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | private struct PasteRowView: View { | ||
| 129 | let paste: Paste | ||
| 130 | |||
| 131 | var body: some View { | ||
| 132 | HStack(alignment: .top, spacing: 12) { | ||
| 133 | Image(systemName: "doc.text") | ||
| 134 | .foregroundStyle(.secondary) | ||
| 135 | .frame(width: 20) | ||
| 136 | |||
| 137 | VStack(alignment: .leading, spacing: 4) { | ||
| 138 | Text(primaryTitle) | ||
| 139 | .font(.subheadline.weight(.medium)) | ||
| 140 | .lineLimit(1) | ||
| 141 | |||
| 142 | Text(secondaryLine) | ||
| 143 | .font(.caption) | ||
| 144 | .foregroundStyle(.secondary) | ||
| 145 | .lineLimit(2) | ||
| 146 | |||
| 147 | HStack(spacing: 8) { | ||
| 148 | VisibilityBadge(visibility: paste.visibility) | ||
| 149 | Text("•") | ||
| 150 | .foregroundStyle(.tertiary) | ||
| 151 | Text(paste.created.relativeDescription) | ||
| 152 | .foregroundStyle(.tertiary) | ||
| 153 | } | ||
| 154 | .font(.caption2) | ||
| 155 | } | ||
| 156 | } | ||
| 157 | .padding(.vertical, 2) | ||
| 158 | } | ||
| 159 | |||
| 160 | private var primaryTitle: String { | ||
| 161 | if let filename = paste.files.first?.filename, !filename.isEmpty { | ||
| 162 | return filename | ||
| 163 | } | ||
| 164 | return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste" | ||
| 165 | } | ||
| 166 | |||
| 167 | private var secondaryLine: String { | ||
| 168 | var parts: [String] = [paste.user.canonicalName] | ||
| 169 | if paste.files.count > 1 { | ||
| 170 | parts.append("\(paste.files.count) files") | ||
| 171 | } else { | ||
| 172 | parts.append("1 file") | ||
| 173 | } | ||
| 174 | if let firstHash = paste.files.first?.hash { | ||
| 175 | parts.append(String(firstHash.prefix(8))) | ||
| 176 | } | ||
| 177 | return parts.joined(separator: " • ") | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | private struct CreatePasteSheet: View { | ||
| 182 | let viewModel: PasteListViewModel | ||
| 183 | let onCreated: (Paste) -> Void | ||
| 184 | |||
| 185 | @Environment(\.dismiss) private var dismiss | ||
| 186 | @State private var files = [PasteUploadDraft()] | ||
| 187 | @State private var visibility: Visibility = .unlisted | ||
| 188 | |||
| 189 | var body: some View { | ||
| 190 | NavigationStack { | ||
| 191 | Form { | ||
| 192 | Section("Files") { | ||
| 193 | ForEach($files) { $file in | ||
| 194 | VStack(alignment: .leading, spacing: 8) { | ||
| 195 | TextField("Filename (optional)", text: $file.filename) | ||
| 196 | .autocorrectionDisabled() | ||
| 197 | .textInputAutocapitalization(.never) | ||
| 198 | |||
| 199 | ZStack(alignment: .topLeading) { | ||
| 200 | if file.contents.isEmpty { | ||
| 201 | Text("Paste contents") | ||
| 202 | .foregroundStyle(.tertiary) | ||
| 203 | .padding(.top, 8) | ||
| 204 | .padding(.leading, 5) | ||
| 205 | .allowsHitTesting(false) | ||
| 206 | } | ||
| 207 | |||
| 208 | TextEditor(text: $file.contents) | ||
| 209 | .font(.system(.body, design: .monospaced)) | ||
| 210 | .frame(minHeight: 180) | ||
| 211 | } | ||
| 212 | } | ||
| 213 | .padding(.vertical, 4) | ||
| 214 | } | ||
| 215 | .onDelete { offsets in | ||
| 216 | files.remove(atOffsets: offsets) | ||
| 217 | if files.isEmpty { | ||
| 218 | files = [PasteUploadDraft()] | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | Button { | ||
| 223 | files.append(PasteUploadDraft()) | ||
| 224 | } label: { | ||
| 225 | Label("Add File", systemImage: "plus") | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 229 | Section("Visibility") { | ||
| 230 | Picker("Visibility", selection: $visibility) { | ||
| 231 | Text("Public").tag(Visibility.public) | ||
| 232 | Text("Unlisted").tag(Visibility.unlisted) | ||
| 233 | Text("Private").tag(Visibility.private) | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | Section { | ||
| 238 | Text("Paste contents are uploaded as UTF-8 text files. Hutch can change visibility later, but the API does not support editing file contents after creation.") | ||
| 239 | .font(.footnote) | ||
| 240 | .foregroundStyle(.secondary) | ||
| 241 | } | ||
| 242 | |||
| 243 | if let error = viewModel.error { | ||
| 244 | Section { | ||
| 245 | Label(error, systemImage: "exclamationmark.triangle.fill") | ||
| 246 | .foregroundStyle(.red) | ||
| 247 | } | ||
| 248 | } | ||
| 249 | } | ||
| 250 | .navigationTitle("New Paste") | ||
| 251 | .navigationBarTitleDisplayMode(.inline) | ||
| 252 | .toolbar { | ||
| 253 | ToolbarItem(placement: .cancellationAction) { | ||
| 254 | Button("Cancel") { dismiss() } | ||
| 255 | } | ||
| 256 | ToolbarItem(placement: .confirmationAction) { | ||
| 257 | Button { | ||
| 258 | Task { | ||
| 259 | if let paste = await viewModel.createPaste(files: files, visibility: visibility) { | ||
| 260 | onCreated(paste) | ||
| 261 | } | ||
| 262 | } | ||
| 263 | } label: { | ||
| 264 | if viewModel.isCreatingPaste { | ||
| 265 | ProgressView() | ||
| 266 | .controlSize(.small) | ||
| 267 | } else { | ||
| 268 | Text("Create Paste") | ||
| 269 | } | ||
| 270 | } | ||
| 271 | .disabled(!hasValidContent || viewModel.isCreatingPaste) | ||
| 272 | } | ||
| 273 | } | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | private var hasValidContent: Bool { | ||
| 278 | files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } | ||
| 279 | } | ||
| 280 | } | ||
Hutch/Views/Pastes/PasteListViewModel.swift added +109
| @@ -0,0 +1,109 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class PasteListViewModel { | ||
| 6 | private(set) var pastes: [Paste] = [] | ||
| 7 | private(set) var isLoading = false | ||
| 8 | private(set) var isLoadingMore = false | ||
| 9 | private(set) var isRefreshing = false | ||
| 10 | private(set) var isCreatingPaste = false | ||
| 11 | var error: String? | ||
| 12 | |||
| 13 | private var cursor: String? | ||
| 14 | private var hasMore = true | ||
| 15 | private let service: PasteService | ||
| 16 | |||
| 17 | init(service: PasteService) { | ||
| 18 | self.service = service | ||
| 19 | } | ||
| 20 | |||
| 21 | func loadPastes() async { | ||
| 22 | if pastes.isEmpty, let cached = service.loadCachedPastes() { | ||
| 23 | pastes = cached.results | ||
| 24 | cursor = cached.cursor | ||
| 25 | hasMore = cached.cursor != nil | ||
| 26 | } | ||
| 27 | |||
| 28 | if pastes.isEmpty { | ||
| 29 | isLoading = true | ||
| 30 | } else { | ||
| 31 | isRefreshing = true | ||
| 32 | } | ||
| 33 | error = nil | ||
| 34 | cursor = nil | ||
| 35 | hasMore = true | ||
| 36 | |||
| 37 | do { | ||
| 38 | let page = try await service.listPastes(cursor: nil, useCache: true) | ||
| 39 | pastes = page.results | ||
| 40 | cursor = page.cursor | ||
| 41 | hasMore = page.cursor != nil | ||
| 42 | } catch { | ||
| 43 | if pastes.isEmpty { | ||
| 44 | self.error = error.localizedDescription | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | isLoading = false | ||
| 49 | isRefreshing = false | ||
| 50 | } | ||
| 51 | |||
| 52 | func loadMoreIfNeeded(currentItem: Paste) async { | ||
| 53 | guard let last = pastes.last, | ||
| 54 | last.id == currentItem.id, | ||
| 55 | hasMore, | ||
| 56 | !isLoadingMore else { | ||
| 57 | return | ||
| 58 | } | ||
| 59 | |||
| 60 | isLoadingMore = true | ||
| 61 | defer { isLoadingMore = false } | ||
| 62 | |||
| 63 | do { | ||
| 64 | let page = try await service.listPastes(cursor: cursor, useCache: false) | ||
| 65 | pastes.append(contentsOf: page.results) | ||
| 66 | cursor = page.cursor | ||
| 67 | hasMore = page.cursor != nil | ||
| 68 | } catch { | ||
| 69 | self.error = error.localizedDescription | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | func createPaste(files: [PasteUploadDraft], visibility: Visibility) async -> Paste? { | ||
| 74 | guard !isCreatingPaste else { return nil } | ||
| 75 | |||
| 76 | let normalizedFiles = files.filter { | ||
| 77 | !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | ||
| 78 | } | ||
| 79 | guard !normalizedFiles.isEmpty else { | ||
| 80 | error = "Add at least one file with text content." | ||
| 81 | return nil | ||
| 82 | } | ||
| 83 | |||
| 84 | isCreatingPaste = true | ||
| 85 | error = nil | ||
| 86 | defer { isCreatingPaste = false } | ||
| 87 | |||
| 88 | do { | ||
| 89 | let paste = try await service.createPaste(files: normalizedFiles, visibility: visibility) | ||
| 90 | upsertPaste(paste) | ||
| 91 | return paste | ||
| 92 | } catch { | ||
| 93 | self.error = error.localizedDescription | ||
| 94 | return nil | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | func upsertPaste(_ paste: Paste) { | ||
| 99 | if let index = pastes.firstIndex(where: { $0.id == paste.id }) { | ||
| 100 | pastes[index] = paste | ||
| 101 | } else { | ||
| 102 | pastes.insert(paste, at: 0) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | func removePaste(id: String) { | ||
| 107 | pastes.removeAll { $0.id == id } | ||
| 108 | } | ||
| 109 | } | ||
Hutch/Views/Projects/ProjectDetailView.swift added +135
| @@ -0,0 +1,135 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct ProjectDetailView: View { | ||
| 4 | let project: Project | ||
| 5 | @Environment(AppState.self) private var appState | ||
| 6 | @Environment(\.dismiss) private var dismiss | ||
| 7 | |||
| 8 | var body: some View { | ||
| 9 | List { | ||
| 10 | headerSection | ||
| 11 | repositoriesSection | ||
| 12 | trackersSection | ||
| 13 | mailingListsSection | ||
| 14 | } | ||
| 15 | .navigationTitle(project.name) | ||
| 16 | .navigationBarTitleDisplayMode(.inline) | ||
| 17 | } | ||
| 18 | |||
| 19 | @ViewBuilder | ||
| 20 | private var headerSection: some View { | ||
| 21 | Section { | ||
| 22 | VStack(alignment: .leading, spacing: 8) { | ||
| 23 | Text(project.name) | ||
| 24 | .font(.headline) | ||
| 25 | |||
| 26 | if let description = project.description, !description.isEmpty { | ||
| 27 | Text(description) | ||
| 28 | .font(.subheadline) | ||
| 29 | .foregroundStyle(.secondary) | ||
| 30 | } | ||
| 31 | |||
| 32 | if let website = project.website, let url = URL(string: website) { | ||
| 33 | Link(destination: url) { | ||
| 34 | Label(website, systemImage: "link") | ||
| 35 | .font(.subheadline) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | .padding(.vertical, 4) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | @ViewBuilder | ||
| 44 | private var repositoriesSection: some View { | ||
| 45 | if !project.sources.isEmpty { | ||
| 46 | Section("Repositories") { | ||
| 47 | ForEach(project.sources) { source in | ||
| 48 | Button { | ||
| 49 | Task { | ||
| 50 | try? await appState.openProjectSource(source) | ||
| 51 | dismiss() | ||
| 52 | } | ||
| 53 | } label: { | ||
| 54 | ProjectResourceRow( | ||
| 55 | title: source.name, | ||
| 56 | subtitle: source.owner.canonicalName, | ||
| 57 | detail: source.description | ||
| 58 | ) | ||
| 59 | } | ||
| 60 | .buttonStyle(.plain) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | @ViewBuilder | ||
| 67 | private var trackersSection: some View { | ||
| 68 | if !project.trackers.isEmpty { | ||
| 69 | Section("Trackers") { | ||
| 70 | ForEach(project.trackers) { tracker in | ||
| 71 | Button { | ||
| 72 | Task { | ||
| 73 | try? await appState.openProjectTracker(tracker) | ||
| 74 | dismiss() | ||
| 75 | } | ||
| 76 | } label: { | ||
| 77 | ProjectResourceRow( | ||
| 78 | title: tracker.name, | ||
| 79 | subtitle: tracker.owner.canonicalName, | ||
| 80 | detail: tracker.description | ||
| 81 | ) | ||
| 82 | } | ||
| 83 | .buttonStyle(.plain) | ||
| 84 | } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | @ViewBuilder | ||
| 90 | private var mailingListsSection: some View { | ||
| 91 | if !project.mailingLists.isEmpty { | ||
| 92 | Section("Mailing Lists") { | ||
| 93 | ForEach(project.mailingLists) { mailingList in | ||
| 94 | Button { | ||
| 95 | appState.openMailingList(mailingList.inboxReference) | ||
| 96 | dismiss() | ||
| 97 | } label: { | ||
| 98 | ProjectResourceRow( | ||
| 99 | title: mailingList.name, | ||
| 100 | subtitle: mailingList.owner.canonicalName, | ||
| 101 | detail: mailingList.description | ||
| 102 | ) | ||
| 103 | } | ||
| 104 | .buttonStyle(.plain) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | } | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | private struct ProjectResourceRow: View { | ||
| 112 | let title: String | ||
| 113 | let subtitle: String | ||
| 114 | let detail: String? | ||
| 115 | |||
| 116 | var body: some View { | ||
| 117 | VStack(alignment: .leading, spacing: 4) { | ||
| 118 | Text(title) | ||
| 119 | .font(.subheadline.weight(.medium)) | ||
| 120 | |||
| 121 | Text(subtitle) | ||
| 122 | .font(.caption) | ||
| 123 | .foregroundStyle(.secondary) | ||
| 124 | .lineLimit(1) | ||
| 125 | |||
| 126 | if let detail, !detail.isEmpty { | ||
| 127 | Text(detail) | ||
| 128 | .font(.caption) | ||
| 129 | .foregroundStyle(.tertiary) | ||
| 130 | .lineLimit(2) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | .padding(.vertical, 2) | ||
| 134 | } | ||
| 135 | } | ||
Hutch/Views/Projects/ProjectMailingListView.swift added +282
| @@ -0,0 +1,282 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | private struct ProjectMailingListThreadsResponse: Decodable, Sendable { | ||
| 4 | let list: ProjectMailingListThreads | ||
| 5 | } | ||
| 6 | |||
| 7 | private struct ProjectMailingListThreads: Decodable, Sendable { | ||
| 8 | let threads: ProjectMailingListThreadPage | ||
| 9 | } | ||
| 10 | |||
| 11 | private struct ProjectMailingListThreadPage: Decodable, Sendable { | ||
| 12 | let results: [ProjectMailingListThreadPayload] | ||
| 13 | } | ||
| 14 | |||
| 15 | private struct ProjectMailingListThreadPayload: Decodable, Sendable { | ||
| 16 | let updated: Date | ||
| 17 | let subject: String | ||
| 18 | let replies: Int | ||
| 19 | let sender: Entity | ||
| 20 | let root: ProjectMailingListRootPayload | ||
| 21 | } | ||
| 22 | |||
| 23 | private struct ProjectMailingListRootPayload: Decodable, Sendable { | ||
| 24 | let id: Int | ||
| 25 | let messageID: String | ||
| 26 | let patch: InboxPatchPreview? | ||
| 27 | } | ||
| 28 | |||
| 29 | @Observable | ||
| 30 | @MainActor | ||
| 31 | final class MailingListDetailViewModel { | ||
| 32 | private(set) var threads: [InboxThreadSummary] = [] | ||
| 33 | private(set) var isLoading = false | ||
| 34 | var error: String? | ||
| 35 | |||
| 36 | private let mailingList: InboxMailingListReference | ||
| 37 | private let client: SRHTClient | ||
| 38 | |||
| 39 | private static let listThreadsQuery = """ | ||
| 40 | query projectMailingListThreads($rid: ID!) { | ||
| 41 | list(rid: $rid) { | ||
| 42 | threads { | ||
| 43 | results { | ||
| 44 | updated | ||
| 45 | subject | ||
| 46 | replies | ||
| 47 | sender { canonicalName } | ||
| 48 | root { | ||
| 49 | id | ||
| 50 | messageID | ||
| 51 | patch { subject } | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | } | ||
| 57 | """ | ||
| 58 | |||
| 59 | init(mailingList: InboxMailingListReference, client: SRHTClient) { | ||
| 60 | self.mailingList = mailingList | ||
| 61 | self.client = client | ||
| 62 | } | ||
| 63 | |||
| 64 | func loadThreads() async { | ||
| 65 | guard !isLoading else { return } | ||
| 66 | isLoading = true | ||
| 67 | error = nil | ||
| 68 | defer { isLoading = false } | ||
| 69 | |||
| 70 | do { | ||
| 71 | let response = try await client.execute( | ||
| 72 | service: .lists, | ||
| 73 | query: Self.listThreadsQuery, | ||
| 74 | variables: ["rid": mailingList.rid], | ||
| 75 | responseType: ProjectMailingListThreadsResponse.self | ||
| 76 | ) | ||
| 77 | |||
| 78 | threads = deduplicateThreads( | ||
| 79 | response.list.threads.results.map(makeSummary(from:)) | ||
| 80 | ) | ||
| 81 | } catch { | ||
| 82 | self.error = "Failed to load mailing list" | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | func markThreadRead(_ thread: InboxThreadSummary) { | ||
| 87 | let viewedAt = max(Date(), thread.lastActivityAt) | ||
| 88 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) | ||
| 89 | updateThread(thread, isUnread: false) | ||
| 90 | } | ||
| 91 | |||
| 92 | func markThreadUnread(_ thread: InboxThreadSummary) { | ||
| 93 | InboxReadStateStore.markUnread(for: thread.id) | ||
| 94 | updateThread(thread, isUnread: true) | ||
| 95 | } | ||
| 96 | |||
| 97 | private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { | ||
| 98 | let normalizedSubject = thread.subject | ||
| 99 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | ||
| 100 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 101 | .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) | ||
| 102 | .lowercased() | ||
| 103 | let threadID = "\(mailingList.rid)#\(normalizedSubject)" | ||
| 104 | |||
| 105 | return InboxThreadSummary( | ||
| 106 | rootEmailID: thread.root.id, | ||
| 107 | rootMessageID: thread.root.messageID, | ||
| 108 | threadRootEmailIDs: [thread.root.id], | ||
| 109 | threadRootMessageIDs: [thread.root.messageID], | ||
| 110 | listID: 0, | ||
| 111 | listRID: mailingList.rid, | ||
| 112 | listName: mailingList.name, | ||
| 113 | listOwner: mailingList.owner, | ||
| 114 | subject: thread.subject, | ||
| 115 | latestSender: thread.sender, | ||
| 116 | lastActivityAt: thread.updated, | ||
| 117 | messageCount: thread.replies + 1, | ||
| 118 | repo: nil, | ||
| 119 | containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), | ||
| 120 | isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) | ||
| 121 | ) | ||
| 122 | } | ||
| 123 | |||
| 124 | private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { | ||
| 125 | guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } | ||
| 126 | let current = threads[index] | ||
| 127 | threads[index] = InboxThreadSummary( | ||
| 128 | rootEmailID: current.rootEmailID, | ||
| 129 | rootMessageID: current.rootMessageID, | ||
| 130 | threadRootEmailIDs: current.threadRootEmailIDs, | ||
| 131 | threadRootMessageIDs: current.threadRootMessageIDs, | ||
| 132 | listID: current.listID, | ||
| 133 | listRID: current.listRID, | ||
| 134 | listName: current.listName, | ||
| 135 | listOwner: current.listOwner, | ||
| 136 | subject: current.subject, | ||
| 137 | latestSender: current.latestSender, | ||
| 138 | lastActivityAt: current.lastActivityAt, | ||
| 139 | messageCount: current.messageCount, | ||
| 140 | repo: current.repo, | ||
| 141 | containsPatch: current.containsPatch, | ||
| 142 | isUnread: isUnread | ||
| 143 | ) | ||
| 144 | } | ||
| 145 | |||
| 146 | private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] { | ||
| 147 | var grouped: [String: InboxThreadSummary] = [:] | ||
| 148 | |||
| 149 | for thread in threads { | ||
| 150 | guard let existing = grouped[thread.threadGroupingKey] else { | ||
| 151 | grouped[thread.threadGroupingKey] = thread | ||
| 152 | continue | ||
| 153 | } | ||
| 154 | |||
| 155 | let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing | ||
| 156 | let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted() | ||
| 157 | let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted() | ||
| 158 | let mergedMessageCount = max( | ||
| 159 | existing.messageCount ?? existing.threadRootMessageIDs.count, | ||
| 160 | thread.messageCount ?? thread.threadRootMessageIDs.count, | ||
| 161 | mergedRootMessageIDs.count | ||
| 162 | ) | ||
| 163 | |||
| 164 | grouped[thread.threadGroupingKey] = InboxThreadSummary( | ||
| 165 | rootEmailID: latest.rootEmailID, | ||
| 166 | rootMessageID: latest.rootMessageID, | ||
| 167 | threadRootEmailIDs: mergedRootEmailIDs, | ||
| 168 | threadRootMessageIDs: mergedRootMessageIDs, | ||
| 169 | listID: latest.listID, | ||
| 170 | listRID: latest.listRID, | ||
| 171 | listName: latest.listName, | ||
| 172 | listOwner: latest.listOwner, | ||
| 173 | subject: latest.subject, | ||
| 174 | latestSender: latest.latestSender, | ||
| 175 | lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt), | ||
| 176 | messageCount: mergedMessageCount, | ||
| 177 | repo: latest.repo ?? existing.repo, | ||
| 178 | containsPatch: latest.containsPatch || existing.containsPatch, | ||
| 179 | isUnread: latest.isUnread || existing.isUnread | ||
| 180 | ) | ||
| 181 | } | ||
| 182 | |||
| 183 | return grouped.values.sorted { lhs, rhs in | ||
| 184 | if lhs.lastActivityAt == rhs.lastActivityAt { | ||
| 185 | return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending | ||
| 186 | } | ||
| 187 | return lhs.lastActivityAt > rhs.lastActivityAt | ||
| 188 | } | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | struct MailingListDetailView: View { | ||
| 193 | let mailingList: InboxMailingListReference | ||
| 194 | |||
| 195 | @Environment(AppState.self) private var appState | ||
| 196 | @State private var viewModel: MailingListDetailViewModel? | ||
| 197 | |||
| 198 | var body: some View { | ||
| 199 | Group { | ||
| 200 | if let viewModel { | ||
| 201 | content(viewModel) | ||
| 202 | } else { | ||
| 203 | SRHTLoadingStateView(message: "Loading mailing list…") | ||
| 204 | } | ||
| 205 | } | ||
| 206 | .navigationTitle(mailingList.name) | ||
| 207 | .navigationBarTitleDisplayMode(.inline) | ||
| 208 | .task { | ||
| 209 | if viewModel == nil { | ||
| 210 | let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client) | ||
| 211 | self.viewModel = viewModel | ||
| 212 | await viewModel.loadThreads() | ||
| 213 | } | ||
| 214 | } | ||
| 215 | .onAppear { | ||
| 216 | guard let viewModel else { return } | ||
| 217 | Task { | ||
| 218 | await viewModel.loadThreads() | ||
| 219 | } | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | @ViewBuilder | ||
| 224 | private func content(_ viewModel: MailingListDetailViewModel) -> some View { | ||
| 225 | @Bindable var vm = viewModel | ||
| 226 | |||
| 227 | List { | ||
| 228 | ForEach(viewModel.threads) { thread in | ||
| 229 | NavigationLink(value: MoreRoute.thread(thread)) { | ||
| 230 | InboxThreadRow(thread: thread) | ||
| 231 | } | ||
| 232 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | ||
| 233 | Button { | ||
| 234 | withAnimation(.easeInOut(duration: 0.2)) { | ||
| 235 | if thread.isUnread { | ||
| 236 | viewModel.markThreadRead(thread) | ||
| 237 | } else { | ||
| 238 | viewModel.markThreadUnread(thread) | ||
| 239 | } | ||
| 240 | } | ||
| 241 | } label: { | ||
| 242 | Label( | ||
| 243 | thread.isUnread ? "Mark as Read" : "Mark as Unread", | ||
| 244 | systemImage: thread.isUnread ? "envelope.open" : "envelope.badge" | ||
| 245 | ) | ||
| 246 | } | ||
| 247 | .tint(thread.isUnread ? .blue : .gray) | ||
| 248 | } | ||
| 249 | } | ||
| 250 | } | ||
| 251 | .listStyle(.plain) | ||
| 252 | .overlay { | ||
| 253 | if viewModel.isLoading, viewModel.threads.isEmpty { | ||
| 254 | SRHTLoadingStateView(message: "Loading mailing list…") | ||
| 255 | } else if let error = viewModel.error, viewModel.threads.isEmpty { | ||
| 256 | SRHTErrorStateView( | ||
| 257 | title: "Couldn't Load Mailing List", | ||
| 258 | message: error, | ||
| 259 | retryAction: { await viewModel.loadThreads() } | ||
| 260 | ) | ||
| 261 | } else if viewModel.threads.isEmpty { | ||
| 262 | ContentUnavailableView( | ||
| 263 | "No Threads", | ||
| 264 | systemImage: "tray", | ||
| 265 | description: Text("This mailing list does not have any recent threads.") | ||
| 266 | ) | ||
| 267 | } | ||
| 268 | } | ||
| 269 | .refreshable { | ||
| 270 | await viewModel.loadThreads() | ||
| 271 | } | ||
| 272 | .srhtErrorBanner(error: $vm.error) | ||
| 273 | } | ||
| 274 | } | ||
| 275 | |||
| 276 | struct ProjectMailingListView: View { | ||
| 277 | let mailingList: Project.MailingList | ||
| 278 | |||
| 279 | var body: some View { | ||
| 280 | MailingListDetailView(mailingList: mailingList.inboxReference) | ||
| 281 | } | ||
| 282 | } | ||
Hutch/Views/Settings/SettingsView.swift +12 −14
| @@ -12,21 +12,19 @@ struct SettingsView: View { | |||
| 12 | @State private var pendingDestructiveAction: SettingsDestructiveAction? | 12 | @State private var pendingDestructiveAction: SettingsDestructiveAction? |
| 13 | 13 | ||
| 14 | var body: some View { | 14 | var body: some View { |
| 15 | NavigationStack { | 15 | Group { |
| 16 | Group { | 16 | if let viewModel { |
| 17 | if let viewModel { | 17 | settingsContent(viewModel) |
| 18 | settingsContent(viewModel) | 18 | } else { |
| 19 | } else { | 19 | SRHTLoadingStateView(message: "Loading profile…") |
| 20 | SRHTLoadingStateView(message: "Loading profile…") | ||
| 21 | } | ||
| 22 | } | 20 | } |
| 23 | .navigationTitle("Settings") | 21 | } |
| 24 | .task { | 22 | .navigationTitle("Settings") |
| 25 | if viewModel == nil { | 23 | .task { |
| 26 | let vm = SettingsViewModel(client: appState.client) | 24 | if viewModel == nil { |
| 27 | viewModel = vm | 25 | let vm = SettingsViewModel(client: appState.client) |
| 28 | await vm.loadProfile() | 26 | viewModel = vm |
| 29 | } | 27 | await vm.loadProfile() |
| 30 | } | 28 | } |
| 31 | } | 29 | } |
| 32 | } | 30 | } |
HutchTests/ProjectTests.swift added +72
| @@ -0,0 +1,72 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct ProjectTests { | ||
| 6 | @Test | ||
| 7 | func resourceSummaryIncludesCounts() { | ||
| 8 | let project = Project( | ||
| 9 | id: "project-1", | ||
| 10 | name: "Hutch", | ||
| 11 | description: nil, | ||
| 12 | website: nil, | ||
| 13 | visibility: .public, | ||
| 14 | tags: [], | ||
| 15 | mailingLists: [ | ||
| 16 | Project.MailingList( | ||
| 17 | id: "list-1", | ||
| 18 | name: "hutch-devel", | ||
| 19 | description: nil, | ||
| 20 | visibility: .public, | ||
| 21 | owner: Entity(canonicalName: "~owner") | ||
| 22 | ) | ||
| 23 | ], | ||
| 24 | sources: [ | ||
| 25 | Project.SourceRepo( | ||
| 26 | id: "repo-1", | ||
| 27 | name: "hutch", | ||
| 28 | description: nil, | ||
| 29 | visibility: .public, | ||
| 30 | owner: Entity(canonicalName: "~owner"), | ||
| 31 | repoType: .git | ||
| 32 | ), | ||
| 33 | Project.SourceRepo( | ||
| 34 | id: "repo-2", | ||
| 35 | name: "hutch-web", | ||
| 36 | description: nil, | ||
| 37 | visibility: .public, | ||
| 38 | owner: Entity(canonicalName: "~owner"), | ||
| 39 | repoType: .git | ||
| 40 | ) | ||
| 41 | ], | ||
| 42 | trackers: [ | ||
| 43 | Project.Tracker( | ||
| 44 | id: "tracker-1", | ||
| 45 | name: "bugs", | ||
| 46 | description: nil, | ||
| 47 | visibility: .public, | ||
| 48 | owner: Entity(canonicalName: "~owner") | ||
| 49 | ) | ||
| 50 | ] | ||
| 51 | ) | ||
| 52 | |||
| 53 | #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list") | ||
| 54 | } | ||
| 55 | |||
| 56 | @Test | ||
| 57 | func resourceSummaryFallsBackToWebsite() { | ||
| 58 | let project = Project( | ||
| 59 | id: "project-1", | ||
| 60 | name: "Docs", | ||
| 61 | description: nil, | ||
| 62 | website: "https://example.com", | ||
| 63 | visibility: .public, | ||
| 64 | tags: [], | ||
| 65 | mailingLists: [], | ||
| 66 | sources: [], | ||
| 67 | trackers: [] | ||
| 68 | ) | ||
| 69 | |||
| 70 | #expect(project.resourceSummary == "Website linked") | ||
| 71 | } | ||
| 72 | } | ||