Commit a9d2e38653
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +4 −4
| @@ -363,7 +363,7 @@ | ||
| 363 | 363 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 364 | 364 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 365 | 365 | CODE_SIGN_STYLE = Automatic; |
| 366 | CURRENT_PROJECT_VERSION = 2; | |
| 366 | CURRENT_PROJECT_VERSION = 5; | |
| 367 | 367 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 368 | 368 | ENABLE_PREVIEWS = YES; |
| 369 | 369 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -380,7 +380,7 @@ | ||
| 380 | 380 | "$(inherited)", |
| 381 | 381 | "@executable_path/Frameworks", |
| 382 | 382 | ); |
| 383 | MARKETING_VERSION = 1.2; | |
| 383 | MARKETING_VERSION = 2.0; | |
| 384 | 384 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 385 | 385 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 386 | 386 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -399,7 +399,7 @@ | ||
| 399 | 399 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 400 | 400 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 401 | 401 | CODE_SIGN_STYLE = Automatic; |
| 402 | CURRENT_PROJECT_VERSION = 2; | |
| 402 | CURRENT_PROJECT_VERSION = 5; | |
| 403 | 403 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 404 | 404 | ENABLE_PREVIEWS = YES; |
| 405 | 405 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -416,7 +416,7 @@ | ||
| 416 | 416 | "$(inherited)", |
| 417 | 417 | "@executable_path/Frameworks", |
| 418 | 418 | ); |
| 419 | MARKETING_VERSION = 1.2; | |
| 419 | MARKETING_VERSION = 2.0; | |
| 420 | 420 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 421 | 421 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 422 | 422 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
Hutch/App/AppState.swift +41 −5
| @@ -9,11 +9,16 @@ final class AppState { | ||
| 9 | 9 | |
| 10 | 10 | enum Tab: Hashable { |
| 11 | 11 | case home |
| 12 | case inbox | |
| 13 | 12 | case repositories |
| 14 | case builds | |
| 15 | 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 | 24 | enum AuthPhase { |
| @@ -48,6 +53,7 @@ final class AppState { | ||
| 48 | 53 | |
| 49 | 54 | /// Set by the deep link handler; consumed by RootView to drive navigation. |
| 50 | 55 | var pendingDeepLink: DeepLink? |
| 56 | var pendingTabNavigation: TabNavigationTarget? | |
| 51 | 57 | |
| 52 | 58 | // MARK: - Init |
| 53 | 59 | |
| @@ -126,9 +132,9 @@ final class AppState { | ||
| 126 | 132 | // MARK: - Deep link resolution |
| 127 | 133 | |
| 128 | 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 | 136 | let result = try await client.execute( |
| 131 | service: .git, | |
| 137 | service: service, | |
| 132 | 138 | query: Self.repoLookupQuery, |
| 133 | 139 | variables: ["owner": owner, "name": name], |
| 134 | 140 | responseType: RepoLookupResponse.self |
| @@ -147,6 +153,35 @@ final class AppState { | ||
| 147 | 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 | 185 | // MARK: - Private |
| 151 | 186 | |
| 152 | 187 | private static let meQuery = """ |
| @@ -221,6 +256,7 @@ final class AppState { | ||
| 221 | 256 | client.responseCache.clear() |
| 222 | 257 | currentUser = nil |
| 223 | 258 | pendingDeepLink = nil |
| 259 | pendingTabNavigation = nil | |
| 224 | 260 | selectedTab = .home |
| 225 | 261 | } |
| 226 | 262 | |
Hutch/App/RootView.swift +94 −16
| @@ -5,7 +5,7 @@ import SwiftUI | ||
| 5 | 5 | struct RootView: View { |
| 6 | 6 | @Environment(AppState.self) private var appState |
| 7 | 7 | @State private var homePath = NavigationPath() |
| 8 | @State private var inboxPath = NavigationPath() | |
| 8 | @State private var morePath = NavigationPath() | |
| 9 | 9 | @State private var repoPath = NavigationPath() |
| 10 | 10 | @State private var buildsPath = NavigationPath() |
| 11 | 11 | @State private var ticketsPath = NavigationPath() |
| @@ -34,6 +34,9 @@ struct RootView: View { | ||
| 34 | 34 | .onChange(of: appState.authPhase) { _, newPhase in |
| 35 | 35 | handleAuthPhaseChange(newPhase) |
| 36 | 36 | } |
| 37 | .onChange(of: appState.pendingTabNavigation) { _, newValue in | |
| 38 | consumePendingTabNavigationIfPossible(newValue) | |
| 39 | } | |
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | // MARK: - Tab View |
| @@ -50,14 +53,6 @@ struct RootView: View { | ||
| 50 | 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 | 56 | NavigationStack(path: $repoPath) { |
| 62 | 57 | RepositoryListView() |
| 63 | 58 | } |
| @@ -78,12 +73,6 @@ struct RootView: View { | ||
| 78 | 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 | 76 | NavigationStack(path: $buildsPath) { |
| 88 | 77 | BuildListView() |
| 89 | 78 | // Int destination used by deep links (hutch://builds/<id>). |
| @@ -96,6 +85,14 @@ struct RootView: View { | ||
| 96 | 85 | .tabItem { |
| 97 | 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 | 97 | .overlay { |
| 101 | 98 | if isResolvingDeepLink { |
| @@ -118,7 +115,7 @@ struct RootView: View { | ||
| 118 | 115 | break |
| 119 | 116 | case .unauthenticated: |
| 120 | 117 | homePath = NavigationPath() |
| 121 | inboxPath = NavigationPath() | |
| 118 | morePath = NavigationPath() | |
| 122 | 119 | repoPath = NavigationPath() |
| 123 | 120 | buildsPath = NavigationPath() |
| 124 | 121 | ticketsPath = NavigationPath() |
| @@ -135,6 +132,12 @@ struct RootView: View { | ||
| 135 | 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 | 141 | private func handleDeepLink(_ link: DeepLink) { |
| 139 | 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 | 193 | private func resolveRepositoryLink(owner: String, repo: String) { |
| 161 | 194 | isResolvingDeepLink = true |
| 162 | 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 | 279 | // MARK: - Ticket Deep Link Navigation Target |
| 202 | 280 | |
| 203 | 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 | 9 | case tracker = "tracker" |
| 10 | 10 | case ticket = "ticket" |
| 11 | 11 | case profile = "profile" |
| 12 | case paste = "paste" | |
| 12 | 13 | |
| 13 | 14 | var fallbackMessage: String { |
| 14 | 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 | 87 | private static func userScopedURL( |
| 80 | 88 | host: String, |
| 81 | 89 | ownerCanonicalName: String, |
Hutch/Models/Inbox.swift +1 −1
| @@ -150,7 +150,7 @@ struct MailComposeDraft: Sendable { | ||
| 150 | 150 | |
| 151 | 151 | extension MailComposeDraft: Identifiable {} |
| 152 | 152 | |
| 153 | struct InboxMailingListReference: Decodable, Sendable, Hashable { | |
| 153 | struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable { | |
| 154 | 154 | let id: Int |
| 155 | 155 | let rid: String |
| 156 | 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 | 34 | try container.encode(v) |
| 35 | 35 | case let v as Bool: |
| 36 | 36 | try container.encode(v) |
| 37 | case let v as [String?]: | |
| 38 | try container.encode(v) | |
| 37 | 39 | case let v as [any Sendable]: |
| 38 | 40 | try container.encode(v.map { AnyCodable($0) }) |
| 39 | 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 | 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 | 13 | /// Placeholder type for decoding GraphQL error responses when the data shape is unknown. |
| 7 | 14 | private struct EmptyData: Decodable {} |
| 8 | 15 | |
| @@ -298,6 +305,100 @@ final class SRHTClient: Sendable { | ||
| 298 | 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 | 402 | // MARK: - Cached Execute |
| 302 | 403 | |
| 303 | 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 | 3 | /// Each Sourcehut service exposes its own GraphQL endpoint. |
| 4 | 4 | enum SRHTService: String, Codable, Sendable, CaseIterable { |
| 5 | 5 | case meta |
| 6 | case hub | |
| 6 | 7 | case git |
| 7 | 8 | case hg |
| 8 | 9 | case builds |
| @@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable { | ||
| 14 | 15 | |
| 15 | 16 | /// The GraphQL endpoint URL for this service. |
| 16 | 17 | var url: URL { |
| 17 | // Force-unwrap is safe here — these are compile-time constant strings. | |
| 18 | URL(string: "https://\(rawValue).sr.ht/query")! | |
| 18 | switch self { | |
| 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 | 27 | var displayName: String { |
| 22 | 28 | switch self { |
| 23 | 29 | case .meta: "Meta" |
| 30 | case .hub: "Hub" | |
| 24 | 31 | case .git: "Git" |
| 25 | 32 | case .hg: "Mercurial" |
| 26 | 33 | case .builds: "Builds" |
Hutch/Views/Home/HomeView.swift +105 −4
| @@ -4,6 +4,7 @@ struct HomeView: View { | ||
| 4 | 4 | @Environment(AppState.self) private var appState |
| 5 | 5 | @State private var viewModel: HomeViewModel? |
| 6 | 6 | private let previewLimit = 4 |
| 7 | private let projectPreviewLimit = 3 | |
| 7 | 8 | |
| 8 | 9 | var body: some View { |
| 9 | 10 | Group { |
| @@ -14,6 +15,15 @@ struct HomeView: View { | ||
| 14 | 15 | } |
| 15 | 16 | } |
| 16 | 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 | 27 | .task { |
| 18 | 28 | if viewModel == nil, let currentUser = appState.currentUser { |
| 19 | 29 | let vm = HomeViewModel(currentUser: currentUser, client: appState.client) |
| @@ -26,16 +36,17 @@ struct HomeView: View { | ||
| 26 | 36 | @ViewBuilder |
| 27 | 37 | private func content(_ viewModel: HomeViewModel) -> some View { |
| 28 | 38 | List { |
| 39 | projectsSection(viewModel) | |
| 29 | 40 | assignedTicketsSection(viewModel) |
| 30 | 41 | recentBuildsSection(viewModel) |
| 31 | 42 | } |
| 32 | 43 | .listStyle(.insetGrouped) |
| 33 | 44 | .overlay { |
| 34 | if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && | |
| 35 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { | |
| 45 | if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && | |
| 46 | viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { | |
| 36 | 47 | SRHTLoadingStateView(message: "Loading Home…") |
| 37 | } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && | |
| 38 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | |
| 48 | } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && | |
| 49 | viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | |
| 39 | 50 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { |
| 40 | 51 | ContentUnavailableView( |
| 41 | 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 | 82 | @ViewBuilder |
| 53 | 83 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { |
| 54 | 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 | 227 | private struct HomeBuildRow: View { |
| 127 | 228 | let build: HomeBuildItem |
| 128 | 229 | |
Hutch/Views/Home/HomeViewModel.swift +184
| @@ -33,6 +33,36 @@ private struct HomeTrackerTicketsPage: Decodable, Sendable { | ||
| 33 | 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 | 66 | private struct HomeTicketPayload: Decodable, Sendable { |
| 37 | 67 | let id: Int |
| 38 | 68 | let title: String |
| @@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { | ||
| 106 | 136 | @Observable |
| 107 | 137 | @MainActor |
| 108 | 138 | final class HomeViewModel { |
| 139 | private(set) var projects: [Project] = [] | |
| 109 | 140 | private(set) var failedBuilds: [HomeBuildItem] = [] |
| 110 | 141 | private(set) var assignedTickets: [HomeAssignedTicket] = [] |
| 111 | 142 | private(set) var recentBuilds: [HomeBuildItem] = [] |
| 143 | private(set) var hasUnreadInboxThreads = false | |
| 144 | private(set) var isLoadingProjects = false | |
| 112 | 145 | private(set) var isLoadingFailedBuilds = false |
| 113 | 146 | private(set) var isLoadingAssignedTickets = false |
| 114 | 147 | private(set) var isLoadingRecentBuilds = false |
| @@ -118,7 +151,10 @@ final class HomeViewModel { | ||
| 118 | 151 | |
| 119 | 152 | private let currentUser: User |
| 120 | 153 | private let client: SRHTClient |
| 154 | private let projectService: ProjectService | |
| 121 | 155 | private let ticketFetchConcurrencyLimit = 6 |
| 156 | private let inboxUnreadConcurrencyLimit = 4 | |
| 157 | private let inboxUnreadThreadPreviewLimit = 10 | |
| 122 | 158 | |
| 123 | 159 | private static let jobsQuery = """ |
| 124 | 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 | 247 | init(currentUser: User, client: SRHTClient) { |
| 181 | 248 | self.currentUser = currentUser |
| 182 | 249 | self.client = client |
| 250 | self.projectService = ProjectService(client: client) | |
| 183 | 251 | } |
| 184 | 252 | |
| 185 | 253 | func loadDashboard() async { |
| 254 | isLoadingProjects = true | |
| 186 | 255 | isLoadingFailedBuilds = true |
| 187 | 256 | isLoadingAssignedTickets = true |
| 188 | 257 | isLoadingRecentBuilds = true |
| @@ -190,8 +259,19 @@ final class HomeViewModel { | ||
| 190 | 259 | assignedTicketsError = nil |
| 191 | 260 | recentBuildsError = nil |
| 192 | 261 | |
| 262 | async let projectsTask = loadProjects() | |
| 193 | 263 | async let jobsTask = loadRecentJobs() |
| 194 | 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 | 276 | let recentJobsResult = await jobsTask |
| 197 | 277 | |
| @@ -222,6 +302,16 @@ final class HomeViewModel { | ||
| 222 | 302 | self.assignedTicketsError = error.localizedDescription |
| 223 | 303 | } |
| 224 | 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 | 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 | 424 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { |
| 241 | 425 | do { |
| 242 | 426 | let trackers = try await fetchAllTrackers() |
Hutch/Views/Inbox/InboxView.swift +6 −9
| @@ -51,9 +51,9 @@ struct InboxView: View { | ||
| 51 | 51 | ) |
| 52 | 52 | } else if viewModel.threads.isEmpty, viewModel.error == nil { |
| 53 | 53 | ContentUnavailableView( |
| 54 | "No Threads", | |
| 54 | "Inbox Zero", | |
| 55 | 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 | 75 | private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { |
| 76 | 76 | Button { |
| 77 | 77 | withAnimation(.easeInOut(duration: 0.2)) { |
| 78 | viewModel.toggleThreadReadState(thread) | |
| 78 | viewModel.markThreadRead(thread) | |
| 79 | 79 | } |
| 80 | 80 | } label: { |
| 81 | Label( | |
| 82 | thread.isUnread ? "Mark as Read" : "Mark as Unread", | |
| 83 | systemImage: thread.isUnread ? "envelope.open" : "envelope.badge" | |
| 84 | ) | |
| 81 | Label("Mark as Read", systemImage: "envelope.open") | |
| 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 | 88 | let thread: InboxThreadSummary |
| 92 | 89 | |
| 93 | 90 | var body: some View { |
Hutch/Views/Inbox/InboxViewModel.swift +8 −2
| @@ -127,7 +127,9 @@ final class InboxViewModel { | ||
| 127 | 127 | let subscriptions = try await fetchSubscriptions() |
| 128 | 128 | let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) |
| 129 | 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 | 133 | if lhs.lastActivityAt == rhs.lastActivityAt { |
| 132 | 134 | return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending |
| 133 | 135 | } |
| @@ -145,7 +147,7 @@ final class InboxViewModel { | ||
| 145 | 147 | inboxListLogger.debug( |
| 146 | 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 | 153 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| @@ -329,6 +331,10 @@ final class InboxViewModel { | ||
| 329 | 331 | private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { |
| 330 | 332 | guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } |
| 331 | 333 | let current = threads[index] |
| 334 | if !isUnread { | |
| 335 | threads.remove(at: index) | |
| 336 | return | |
| 337 | } | |
| 332 | 338 | threads[index] = InboxThreadSummary( |
| 333 | 339 | rootEmailID: current.rootEmailID, |
| 334 | 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 | 8 | struct ThreadDetailView: View { |
| 9 | 9 | let thread: InboxThreadSummary |
| 10 | 10 | let onViewed: () -> Void |
| 11 | var onMarkRead: (() -> Void)? = nil | |
| 12 | var onMarkUnread: (() -> Void)? = nil | |
| 11 | 13 | |
| 12 | 14 | @Environment(AppState.self) private var appState |
| 13 | 15 | @State private var viewModel: ThreadViewModel? |
| 14 | 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 | 35 | var body: some View { |
| 17 | 36 | Group { |
| @@ -23,13 +42,21 @@ struct ThreadDetailView: View { | ||
| 23 | 42 | } |
| 24 | 43 | .navigationTitle("Thread") |
| 25 | 44 | .navigationBarTitleDisplayMode(.inline) |
| 26 | .task { | |
| 27 | if viewModel == nil { | |
| 28 | onViewed() | |
| 29 | let vm = ThreadViewModel(summary: thread, client: appState.client) | |
| 30 | viewModel = vm | |
| 31 | await vm.loadThread() | |
| 32 | } | |
| 45 | .task(id: thread.id) { | |
| 46 | guard loadedThreadID != thread.id else { return } | |
| 47 | let vm = ThreadViewModel(summary: thread, client: appState.client) | |
| 48 | viewModel = vm | |
| 49 | loadedThreadID = thread.id | |
| 50 | hasMarkedCurrentThreadViewed = false | |
| 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 | 61 | .sheet(item: Binding( |
| 35 | 62 | get: { viewModel?.composeDraft }, |
| @@ -109,8 +136,23 @@ struct ThreadDetailView: View { | ||
| 109 | 136 | .listStyle(.plain) |
| 110 | 137 | .toolbar { |
| 111 | 138 | ToolbarItem(placement: .topBarTrailing) { |
| 112 | Button("Reply") { | |
| 113 | viewModel.prepareReply() | |
| 139 | HStack { | |
| 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 | 12 | @State private var pendingDestructiveAction: SettingsDestructiveAction? |
| 13 | 13 | |
| 14 | 14 | var body: some View { |
| 15 | NavigationStack { | |
| 16 | Group { | |
| 17 | if let viewModel { | |
| 18 | settingsContent(viewModel) | |
| 19 | } else { | |
| 20 | SRHTLoadingStateView(message: "Loading profile…") | |
| 21 | } | |
| 15 | Group { | |
| 16 | if let viewModel { | |
| 17 | settingsContent(viewModel) | |
| 18 | } else { | |
| 19 | SRHTLoadingStateView(message: "Loading profile…") | |
| 22 | 20 | } |
| 23 | .navigationTitle("Settings") | |
| 24 | .task { | |
| 25 | if viewModel == nil { | |
| 26 | let vm = SettingsViewModel(client: appState.client) | |
| 27 | viewModel = vm | |
| 28 | await vm.loadProfile() | |
| 29 | } | |
| 21 | } | |
| 22 | .navigationTitle("Settings") | |
| 23 | .task { | |
| 24 | if viewModel == nil { | |
| 25 | let vm = SettingsViewModel(client: appState.client) | |
| 26 | viewModel = vm | |
| 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 | } | |