diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
| commit | 6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (patch) | |
| tree | 572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch | |
| parent | 1e3c748119c6e9eec27f02146f17ea0302ff648a (diff) | |
| download | hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.gz hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.bz2 hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.zip | |
feat: implement support for projects, lists, and pastes
Diffstat (limited to 'Hutch')
26 files changed, 2675 insertions, 62 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 7492fa6..d5a86fd 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -9,11 +9,16 @@ final class AppState { enum Tab: Hashable { case home - case inbox case repositories - case builds case tickets - case settings + case builds + case more + } + + enum TabNavigationTarget: Hashable { + case repository(RepositorySummary) + case tracker(TrackerSummary) + case mailingList(InboxMailingListReference) } enum AuthPhase { @@ -48,6 +53,7 @@ final class AppState { /// Set by the deep link handler; consumed by RootView to drive navigation. var pendingDeepLink: DeepLink? + var pendingTabNavigation: TabNavigationTarget? // MARK: - Init @@ -126,9 +132,9 @@ final class AppState { // MARK: - Deep link resolution /// Resolve a repository by owner and name for deep linking. - func resolveRepository(owner: String, name: String) async throws -> RepositorySummary { + func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary { let result = try await client.execute( - service: .git, + service: service, query: Self.repoLookupQuery, variables: ["owner": owner, "name": name], responseType: RepoLookupResponse.self @@ -147,6 +153,35 @@ final class AppState { return result.user.tracker } + func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary { + try await resolveRepository( + owner: source.ownerUsername, + name: source.name, + service: source.repoType.service + ) + } + + func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary { + try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name) + } + + func openProjectSource(_ source: Project.SourceRepo) async throws { + let repository = try await resolveProjectSource(source) + pendingTabNavigation = .repository(repository) + selectedTab = .repositories + } + + func openProjectTracker(_ tracker: Project.Tracker) async throws { + let resolvedTracker = try await resolveProjectTracker(tracker) + pendingTabNavigation = .tracker(resolvedTracker) + selectedTab = .tickets + } + + func openMailingList(_ mailingList: InboxMailingListReference) { + pendingTabNavigation = .mailingList(mailingList) + selectedTab = .more + } + // MARK: - Private private static let meQuery = """ @@ -221,6 +256,7 @@ final class AppState { client.responseCache.clear() currentUser = nil pendingDeepLink = nil + pendingTabNavigation = nil selectedTab = .home } diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 042494f..0885835 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -5,7 +5,7 @@ import SwiftUI struct RootView: View { @Environment(AppState.self) private var appState @State private var homePath = NavigationPath() - @State private var inboxPath = NavigationPath() + @State private var morePath = NavigationPath() @State private var repoPath = NavigationPath() @State private var buildsPath = NavigationPath() @State private var ticketsPath = NavigationPath() @@ -34,6 +34,9 @@ struct RootView: View { .onChange(of: appState.authPhase) { _, newPhase in handleAuthPhaseChange(newPhase) } + .onChange(of: appState.pendingTabNavigation) { _, newValue in + consumePendingTabNavigationIfPossible(newValue) + } } // MARK: - Tab View @@ -50,14 +53,6 @@ struct RootView: View { Label("Home", systemImage: "house") } - NavigationStack(path: $inboxPath) { - InboxView() - } - .tag(AppState.Tab.inbox) - .tabItem { - Label("Inbox", systemImage: "tray") - } - NavigationStack(path: $repoPath) { RepositoryListView() } @@ -78,12 +73,6 @@ struct RootView: View { Label("Tickets", systemImage: "ticket") } - SettingsView() - .tag(AppState.Tab.settings) - .tabItem { - Label("Settings", systemImage: "gear") - } - NavigationStack(path: $buildsPath) { BuildListView() // Int destination used by deep links (hutch://builds/<id>). @@ -96,6 +85,14 @@ struct RootView: View { .tabItem { Label("Builds", systemImage: "hammer") } + + NavigationStack(path: $morePath) { + MoreNavigationRoot() + } + .tag(AppState.Tab.more) + .tabItem { + Label("More", systemImage: "ellipsis.circle") + } } .overlay { if isResolvingDeepLink { @@ -118,7 +115,7 @@ struct RootView: View { break case .unauthenticated: homePath = NavigationPath() - inboxPath = NavigationPath() + morePath = NavigationPath() repoPath = NavigationPath() buildsPath = NavigationPath() ticketsPath = NavigationPath() @@ -135,6 +132,12 @@ struct RootView: View { appState.pendingDeepLink = nil } + private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) { + guard appState.isAuthenticated, let target else { return } + handleTabNavigation(target) + appState.pendingTabNavigation = nil + } + private func handleDeepLink(_ link: DeepLink) { guard appState.isAuthenticated else { return } @@ -157,6 +160,36 @@ struct RootView: View { } } + private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { + switch target { + case .repository(let repository): + repoPath = NavigationPath() + appState.selectedTab = .repositories + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + repoPath.append(repository) + } + + case .tracker(let tracker): + ticketsPath = NavigationPath() + appState.selectedTab = .tickets + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + ticketsPath.append(tracker) + } + + case .mailingList(let mailingList): + morePath = NavigationPath() + appState.selectedTab = .more + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + morePath.append(MoreRoute.lists) + try? await Task.sleep(for: .milliseconds(100)) + morePath.append(MoreRoute.mailingList(mailingList)) + } + } + } + private func resolveRepositoryLink(owner: String, repo: String) { isResolvingDeepLink = true Task { @@ -198,6 +231,51 @@ struct RootView: View { } } +enum MoreDestination: Hashable { + case lists + case pastes + case settings +} + +enum MoreRoute: Hashable { + case lists + case pastes + case settings + case mailingList(InboxMailingListReference) + case thread(InboxThreadSummary) +} + +private struct MoreNavigationRoot: View { + var body: some View { + MoreView() + .navigationDestination(for: MoreRoute.self) { route in + switch route { + case .lists: + MailingListListView() + case .pastes: + PasteListView() + case .settings: + SettingsView() + case .mailingList(let mailingList): + MailingListDetailView(mailingList: mailingList) + case .thread(let thread): + ThreadDetailView( + thread: thread, + onViewed: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + }, + onMarkRead: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + }, + onMarkUnread: { + InboxReadStateStore.markUnread(for: thread.id) + } + ) + } + } + } +} + // MARK: - Ticket Deep Link Navigation Target /// Hashable wrapper to push a ticket detail view from a deep link. diff --git a/Hutch/Extensions/SRHTShareUI.swift b/Hutch/Extensions/SRHTShareUI.swift index 9d949c9..01f3d91 100644 --- a/Hutch/Extensions/SRHTShareUI.swift +++ b/Hutch/Extensions/SRHTShareUI.swift @@ -9,6 +9,7 @@ enum SRHTShareTarget: String { case tracker = "tracker" case ticket = "ticket" case profile = "profile" + case paste = "paste" var fallbackMessage: String { "This \(rawValue) does not have a valid web URL to share." diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift index f63352b..35d250d 100644 --- a/Hutch/Extensions/SRHTWebURL.swift +++ b/Hutch/Extensions/SRHTWebURL.swift @@ -76,6 +76,14 @@ enum SRHTWebURL { ) } + static func paste(ownerCanonicalName: String, pasteId: String) -> URL? { + userScopedURL( + host: "paste.sr.ht", + ownerCanonicalName: ownerCanonicalName, + pathComponents: [pasteId] + ) + } + private static func userScopedURL( host: String, ownerCanonicalName: String, diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift index 54e2685..c89cb12 100644 --- a/Hutch/Models/Inbox.swift +++ b/Hutch/Models/Inbox.swift @@ -150,7 +150,7 @@ struct MailComposeDraft: Sendable { extension MailComposeDraft: Identifiable {} -struct InboxMailingListReference: Decodable, Sendable, Hashable { +struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable { let id: Int let rid: String let name: String diff --git a/Hutch/Models/Paste.swift b/Hutch/Models/Paste.swift new file mode 100644 index 0000000..b4c8f18 --- /dev/null +++ b/Hutch/Models/Paste.swift @@ -0,0 +1,29 @@ +import Foundation + +struct PasteFile: Codable, Sendable, Hashable, Identifiable { + let filename: String? + let hash: String + let contents: URL? + + var id: String { hash } +} + +struct Paste: Codable, Sendable, Identifiable, Hashable { + let id: String + let created: Date + let visibility: Visibility + let files: [PasteFile] + let user: Entity +} + +struct PasteUploadDraft: Identifiable, Equatable, Sendable { + let id: UUID + var filename: String + var contents: String + + init(id: UUID = UUID(), filename: String = "", contents: String = "") { + self.id = id + self.filename = filename + self.contents = contents + } +} diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift new file mode 100644 index 0000000..e6726c2 --- /dev/null +++ b/Hutch/Models/Project.swift @@ -0,0 +1,101 @@ +import Foundation + +struct Project: Identifiable, Hashable, Sendable { + struct MailingList: Identifiable, Hashable, Sendable { + let id: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + + var ownerUsername: String { + owner.canonicalName.srhtUsername + } + + var inboxReference: InboxMailingListReference { + InboxMailingListReference( + id: 0, + rid: id, + name: name, + owner: owner + ) + } + } + + struct SourceRepo: Identifiable, Hashable, Sendable { + enum RepoType: String, Decodable, Sendable { + case git = "GIT" + case hg = "HG" + + var service: SRHTService { + switch self { + case .git: .git + case .hg: .hg + } + } + } + + let id: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + let repoType: RepoType + + var ownerUsername: String { + owner.canonicalName.srhtUsername + } + } + + struct Tracker: Identifiable, Hashable, Sendable { + let id: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + + var ownerUsername: String { + owner.canonicalName.srhtUsername + } + } + + let id: String + let name: String + let description: String? + let website: String? + let visibility: Visibility + let tags: [String] + let mailingLists: [MailingList] + let sources: [SourceRepo] + let trackers: [Tracker] + + var resourceSummary: String? { + let parts = [ + Self.resourceCountText(count: sources.count, singular: "repo"), + Self.resourceCountText(count: trackers.count, singular: "tracker"), + Self.resourceCountText(count: mailingLists.count, singular: "list") + ].compactMap { $0 } + + if !parts.isEmpty { + return parts.joined(separator: " • ") + } + + if let website, !website.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Website linked" + } + + return nil + } + + private static func resourceCountText(count: Int, singular: String) -> String? { + guard count > 0 else { return nil } + let label = count == 1 ? singular : "\(singular)s" + return "\(count) \(label)" + } +} + +extension String { + var srhtUsername: String { + hasPrefix("~") ? String(dropFirst()) : self + } +} diff --git a/Hutch/Networking/GraphQLRequest.swift b/Hutch/Networking/GraphQLRequest.swift index bce7b40..8924e6a 100644 --- a/Hutch/Networking/GraphQLRequest.swift +++ b/Hutch/Networking/GraphQLRequest.swift @@ -34,6 +34,8 @@ struct AnyCodable: Sendable, Encodable { try container.encode(v) case let v as Bool: try container.encode(v) + case let v as [String?]: + try container.encode(v) case let v as [any Sendable]: try container.encode(v.map { AnyCodable($0) }) case let v as [String: any Sendable]: diff --git a/Hutch/Networking/PasteService.swift b/Hutch/Networking/PasteService.swift new file mode 100644 index 0000000..b9aa530 --- /dev/null +++ b/Hutch/Networking/PasteService.swift @@ -0,0 +1,231 @@ +import Foundation + +struct PasteListPage: Decodable, Sendable { + let results: [Paste] + let cursor: String? +} + +final class PasteService: Sendable { + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + private static let listQuery = """ + query pastes($cursor: Cursor) { + pastes(cursor: $cursor) { + results { + id + created + visibility + files { + filename + hash + } + user { + canonicalName + } + } + cursor + } + } + """ + + private static let detailQuery = """ + query paste($id: String!) { + paste(id: $id) { + id + created + visibility + files { + filename + hash + contents + } + user { + canonicalName + } + } + } + """ + + private static let createMutation = """ + mutation createPaste($files: [Upload!]!, $visibility: Visibility!) { + create(files: $files, visibility: $visibility) { + id + created + visibility + files { + filename + hash + contents + } + user { + canonicalName + } + } + } + """ + + private static let updateMutation = """ + mutation updatePaste($id: String!, $visibility: Visibility!) { + update(id: $id, visibility: $visibility) { + id + created + visibility + files { + filename + hash + contents + } + user { + canonicalName + } + } + } + """ + + private static let deleteMutation = """ + mutation deletePaste($id: String!) { + delete(id: $id) { + id + created + visibility + files { + filename + hash + } + user { + canonicalName + } + } + } + """ + + private static let cacheKey = "paste.pastes" + + func listPastes(cursor: String?, useCache: Bool) async throws -> PasteListPage { + let variables = cursor.map { ["cursor": $0 as any Sendable] } + let result: PasteListResponse + if useCache, cursor == nil { + result = try await client.executeAndCache( + service: .paste, + query: Self.listQuery, + variables: variables, + responseType: PasteListResponse.self, + cacheKey: Self.cacheKey + ) + } else { + result = try await client.execute( + service: .paste, + query: Self.listQuery, + variables: variables, + responseType: PasteListResponse.self + ) + } + return result.pastes ?? PasteListPage(results: [], cursor: nil) + } + + func loadCachedPastes() -> PasteListPage? { + guard let data = client.responseCache.get(forKey: Self.cacheKey) else { + return nil + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + guard let response = try? decoder.decode(GraphQLResponse<PasteListResponse>.self, from: data) else { + return nil + } + return response.data?.pastes + } + + func loadPaste(id: String) async throws -> Paste? { + let result = try await client.execute( + service: .paste, + query: Self.detailQuery, + variables: ["id": id], + responseType: PasteDetailResponse.self + ) + return result.paste + } + + func createPaste(files: [PasteUploadDraft], visibility: Visibility) async throws -> Paste { + let uploadFiles = normalizedUploadFiles(from: files) + let variables: [String: any Sendable] = [ + "files": [String?](repeating: nil, count: uploadFiles.count), + "visibility": visibility.rawValue + ] + + let result = try await client.executeMultipartFiles( + service: .paste, + query: Self.createMutation, + variables: variables, + files: uploadFiles.enumerated().map { index, file in + MultipartUploadFile( + variablePath: "files.\(index)", + fileData: file.data, + fileName: file.fileName, + mimeType: "text/plain" + ) + }, + responseType: CreatePasteResponse.self + ) + return result.create + } + + func updateVisibility(id: String, visibility: Visibility) async throws -> Paste? { + let result = try await client.execute( + service: .paste, + query: Self.updateMutation, + variables: ["id": id, "visibility": visibility.rawValue], + responseType: UpdatePasteResponse.self + ) + return result.update + } + + func deletePaste(id: String) async throws -> Paste? { + let result = try await client.execute( + service: .paste, + query: Self.deleteMutation, + variables: ["id": id], + responseType: DeletePasteResponse.self + ) + return result.delete + } + + func loadContents(from url: URL) async throws -> String { + try await client.fetchText(url: url) + } + + private func normalizedUploadFiles(from files: [PasteUploadDraft]) -> [(fileName: String, data: Data)] { + files.compactMap { draft in + let text = draft.contents + guard let data = text.data(using: .utf8) else { + return nil + } + + return (draft.filename, data) + } + } +} + +private struct PasteListResponse: Decodable, Sendable { + let pastes: PasteListPage? +} + +private struct PasteDetailResponse: Decodable, Sendable { + let paste: Paste? +} + +private struct CreatePasteResponse: Decodable, Sendable { + let create: Paste +} + +private struct UpdatePasteResponse: Decodable, Sendable { + let update: Paste? +} + +private struct DeletePasteResponse: Decodable, Sendable { + let delete: Paste? +} diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift new file mode 100644 index 0000000..2afb699 --- /dev/null +++ b/Hutch/Networking/ProjectService.swift @@ -0,0 +1,284 @@ +import Foundation + +private struct ProjectPageResponse: Decodable, Sendable { + let me: ProjectPageUser +} + +private struct ProjectPageUser: Decodable, Sendable { + let projects: ProjectPage +} + +private struct ProjectPage: Decodable, Sendable { + let results: [ProjectSummaryPayload] + let cursor: String? +} + +private struct ProjectSummaryPayload: Decodable, Sendable { + let rid: String + let name: String + let description: String? + let website: String? + let visibility: Visibility + let tags: [String] +} + +private struct ProjectDetailResponse: Decodable, Sendable { + let project: ProjectDetailPayload? +} + +private struct ProjectDetailPayload: Decodable, Sendable { + let rid: String + let name: String + let description: String? + let website: String? + let visibility: Visibility + let tags: [String] + let mailingLists: ProjectMailingListPage + let sources: ProjectSourcePage + let trackers: ProjectTrackerPage +} + +private struct ProjectMailingListPage: Decodable, Sendable { + let results: [ProjectMailingListPayload] + let cursor: String? +} + +private struct ProjectMailingListPayload: Decodable, Sendable { + let rid: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity +} + +private struct ProjectSourcePage: Decodable, Sendable { + let results: [ProjectSourcePayload] + let cursor: String? +} + +private struct ProjectSourcePayload: Decodable, Sendable { + let rid: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + let repoType: Project.SourceRepo.RepoType +} + +private struct ProjectTrackerPage: Decodable, Sendable { + let results: [ProjectTrackerPayload] + let cursor: String? +} + +private struct ProjectTrackerPayload: Decodable, Sendable { + let rid: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity +} + +struct ProjectService: Sendable { + private let client: SRHTClient + + private static let projectsQuery = """ + query meProjects($cursor: Cursor) { + me { + projects(cursor: $cursor) { + results { + rid + name + description + website + visibility + tags + } + cursor + } + } + } + """ + + private static let projectDetailQuery = """ + query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) { + project(rid: $rid) { + rid + name + description + website + visibility + tags + mailingLists(cursor: $mailingListsCursor) { + results { + rid + name + description + visibility + owner { canonicalName } + } + cursor + } + sources(cursor: $sourcesCursor) { + results { + rid + name + description + visibility + owner { canonicalName } + repoType + } + cursor + } + trackers(cursor: $trackersCursor) { + results { + rid + name + description + visibility + owner { canonicalName } + } + cursor + } + } + } + """ + + init(client: SRHTClient) { + self.client = client + } + + func fetchProjects() async throws -> [Project] { + let summaries = try await fetchProjectSummaries() + guard !summaries.isEmpty else { return [] } + + return try await withThrowingTaskGroup(of: Project.self) { group in + for summary in summaries { + group.addTask { + try await self.fetchProjectDetail(summary: summary) + } + } + + var projects: [Project] = [] + for try await project in group { + projects.append(project) + } + return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + } + + private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] { + var results: [ProjectSummaryPayload] = [] + var cursor: String? + + while true { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .hub, + query: Self.projectsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: ProjectPageResponse.self + ) + + results.append(contentsOf: response.me.projects.results) + guard let nextCursor = response.me.projects.cursor else { + break + } + cursor = nextCursor + } + + return results + } + + private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project { + var mailingLists: [Project.MailingList] = [] + var sources: [Project.SourceRepo] = [] + var trackers: [Project.Tracker] = [] + var mailingListsCursor: String? + var sourcesCursor: String? + var trackersCursor: String? + + while true { + var variables: [String: any Sendable] = ["rid": summary.rid] + if let mailingListsCursor { + variables["mailingListsCursor"] = mailingListsCursor + } + if let sourcesCursor { + variables["sourcesCursor"] = sourcesCursor + } + if let trackersCursor { + variables["trackersCursor"] = trackersCursor + } + + let response = try await client.execute( + service: .hub, + query: Self.projectDetailQuery, + variables: variables, + responseType: ProjectDetailResponse.self + ) + + guard let project = response.project else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload")) + ) + } + + mailingLists.append(contentsOf: project.mailingLists.results.map { + Project.MailingList( + id: $0.rid, + name: $0.name, + description: $0.description, + visibility: $0.visibility, + owner: $0.owner + ) + }) + sources.append(contentsOf: project.sources.results.map { + Project.SourceRepo( + id: $0.rid, + name: $0.name, + description: $0.description, + visibility: $0.visibility, + owner: $0.owner, + repoType: $0.repoType + ) + }) + trackers.append(contentsOf: project.trackers.results.map { + Project.Tracker( + id: $0.rid, + name: $0.name, + description: $0.description, + visibility: $0.visibility, + owner: $0.owner + ) + }) + + mailingListsCursor = project.mailingLists.cursor + sourcesCursor = project.sources.cursor + trackersCursor = project.trackers.cursor + + if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil { + return Project( + id: project.rid, + name: project.name, + description: project.description, + website: project.website, + visibility: project.visibility, + tags: project.tags, + mailingLists: deduplicate(mailingLists), + sources: deduplicate(sources), + trackers: deduplicate(trackers) + ) + } + } + } + + private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable { + var seen = Set<T.ID>() + return items.filter { item in + seen.insert(item.id).inserted + } + } +} diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index 6032cab..4a6b3ca 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -3,6 +3,13 @@ import os private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient") +struct MultipartUploadFile: Sendable { + let variablePath: String + let fileData: Data + let fileName: String + let mimeType: String +} + /// Placeholder type for decoding GraphQL error responses when the data shape is unknown. private struct EmptyData: Decodable {} @@ -298,6 +305,100 @@ final class SRHTClient: Sendable { return result } + func executeMultipartFiles<T: Decodable>( + service: SRHTService, + query: String, + variables: [String: any Sendable], + files: [MultipartUploadFile], + responseType: T.Type + ) async throws -> T { + guard let token = _token.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + let boundary = "Boundary-\(UUID().uuidString)" + + var request = URLRequest(url: service.url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + let operationsBody = GraphQLRequestBody( + query: query, + variables: variables.mapValues { AnyCodable($0) } + ) + let operationsData = try encoder.encode(operationsBody) + + let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in + (String(index), ["variables.\(file.variablePath)"]) + }) + let mapData = try encoder.encode(mapDict) + + var body = Data() + + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"operations\"\r\n") + body.append("Content-Type: application/json\r\n\r\n") + body.append(operationsData) + body.append("\r\n") + + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"map\"\r\n") + body.append("Content-Type: application/json\r\n\r\n") + body.append(mapData) + body.append("\r\n") + + for (index, file) in files.enumerated() { + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n") + body.append("Content-Type: \(file.mimeType)\r\n\r\n") + body.append(file.fileData) + body.append("\r\n") + } + + body.append("--\(boundary)--\r\n") + request.httpBody = body + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), + let errors = gqlResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + throw SRHTError.httpError(http.statusCode) + } + } + + let graphQLResponse: GraphQLResponse<T> + do { + graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) + } catch { + throw SRHTError.decodingError(error) + } + + if let errors = graphQLResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + + guard let result = graphQLResponse.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) + ) + } + + return result + } + // MARK: - Cached Execute /// Execute a query and cache the raw response data. Returns cached data diff --git a/Hutch/Networking/SRHTService.swift b/Hutch/Networking/SRHTService.swift index 5db06dd..416b733 100644 --- a/Hutch/Networking/SRHTService.swift +++ b/Hutch/Networking/SRHTService.swift @@ -3,6 +3,7 @@ import Foundation /// Each Sourcehut service exposes its own GraphQL endpoint. enum SRHTService: String, Codable, Sendable, CaseIterable { case meta + case hub case git case hg case builds @@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable { /// The GraphQL endpoint URL for this service. var url: URL { - // Force-unwrap is safe here — these are compile-time constant strings. - URL(string: "https://\(rawValue).sr.ht/query")! + switch self { + case .hub: + URL(string: "https://sr.ht/query")! + default: + // Force-unwrap is safe here — these are compile-time constant strings. + URL(string: "https://\(rawValue).sr.ht/query")! + } } var displayName: String { switch self { case .meta: "Meta" + case .hub: "Hub" case .git: "Git" case .hg: "Mercurial" case .builds: "Builds" diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 1e98039..55ec41e 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -4,6 +4,7 @@ struct HomeView: View { @Environment(AppState.self) private var appState @State private var viewModel: HomeViewModel? private let previewLimit = 4 + private let projectPreviewLimit = 3 var body: some View { Group { @@ -14,6 +15,15 @@ struct HomeView: View { } } .navigationTitle("Home") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + InboxView() + } label: { + HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true) + } + } + } .task { if viewModel == nil, let currentUser = appState.currentUser { let vm = HomeViewModel(currentUser: currentUser, client: appState.client) @@ -26,16 +36,17 @@ struct HomeView: View { @ViewBuilder private func content(_ viewModel: HomeViewModel) -> some View { List { + projectsSection(viewModel) assignedTicketsSection(viewModel) recentBuildsSection(viewModel) } .listStyle(.insetGrouped) .overlay { - if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && - viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { SRHTLoadingStateView(message: "Loading Home…") - } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && - viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { ContentUnavailableView( "All Clear", @@ -50,6 +61,25 @@ struct HomeView: View { } @ViewBuilder + private func projectsSection(_ viewModel: HomeViewModel) -> some View { + if !viewModel.projects.isEmpty { + Section { + ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in + NavigationLink { + ProjectDetailView(project: project) + } label: { + HomeProjectRow(project: project) + } + } + } header: { + HomeSectionHeader("Projects") { + HomeProjectsListView(viewModel: viewModel) + } + } + } + } + + @ViewBuilder private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { Section { if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { @@ -123,6 +153,77 @@ struct HomeView: View { } +private struct HomeInboxToolbarIcon: View { + let hasUnreadThreads: Bool + + var body: some View { + ZStack(alignment: .topTrailing) { + Image(systemName: hasUnreadThreads ? "tray.fill" : "tray") + + if hasUnreadThreads { + Circle() + .fill(.blue) + .frame(width: 9, height: 9) + .offset(x: 4, y: -2) + } + } + .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox") + } +} + +private struct HomeProjectRow: View { + let project: Project + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(project.name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + if let description = project.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let summary = project.resourceSummary { + Text(summary) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + .padding(.vertical, 2) + } +} + +private struct HomeProjectsListView: View { + let viewModel: HomeViewModel + + var body: some View { + List { + ForEach(viewModel.projects) { project in + NavigationLink { + ProjectDetailView(project: project) + } label: { + HomeProjectRow(project: project) + } + } + } + .navigationTitle("Projects") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await viewModel.loadDashboard() + } + .overlay { + if viewModel.isLoadingProjects && viewModel.projects.isEmpty { + SRHTLoadingStateView(message: "Loading projects…") + } + } + } +} + private struct HomeBuildRow: View { let build: HomeBuildItem diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index ce27bb0..31dec1b 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -33,6 +33,36 @@ private struct HomeTrackerTicketsPage: Decodable, Sendable { let results: [HomeTicketPayload] } +private struct HomeInboxSubscriptionsResponse: Decodable, Sendable { + let subscriptions: HomeInboxSubscriptionPage +} + +private struct HomeInboxSubscriptionPage: Decodable, Sendable { + let results: [HomeInboxSubscription] + let cursor: String? +} + +private struct HomeInboxSubscription: Decodable, Sendable { + let list: InboxMailingListReference? +} + +private struct HomeInboxListThreadsResponse: Decodable, Sendable { + let list: HomeInboxMailingListThreads +} + +private struct HomeInboxMailingListThreads: Decodable, Sendable { + let threads: HomeInboxThreadPage +} + +private struct HomeInboxThreadPage: Decodable, Sendable { + let results: [HomeInboxThreadPayload] +} + +private struct HomeInboxThreadPayload: Decodable, Sendable { + let updated: Date + let subject: String +} + private struct HomeTicketPayload: Decodable, Sendable { let id: Int let title: String @@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { @Observable @MainActor final class HomeViewModel { + private(set) var projects: [Project] = [] private(set) var failedBuilds: [HomeBuildItem] = [] private(set) var assignedTickets: [HomeAssignedTicket] = [] private(set) var recentBuilds: [HomeBuildItem] = [] + private(set) var hasUnreadInboxThreads = false + private(set) var isLoadingProjects = false private(set) var isLoadingFailedBuilds = false private(set) var isLoadingAssignedTickets = false private(set) var isLoadingRecentBuilds = false @@ -118,7 +151,10 @@ final class HomeViewModel { private let currentUser: User private let client: SRHTClient + private let projectService: ProjectService private let ticketFetchConcurrencyLimit = 6 + private let inboxUnreadConcurrencyLimit = 4 + private let inboxUnreadThreadPreviewLimit = 10 private static let jobsQuery = """ query jobs { @@ -177,12 +213,45 @@ final class HomeViewModel { } """ + private static let inboxSubscriptionsQuery = """ + query inboxSubscriptions($cursor: Cursor) { + subscriptions(cursor: $cursor) { + results { + ... on MailingListSubscription { + list { + id + rid + name + owner { canonicalName } + } + } + } + cursor + } + } + """ + + private static let inboxListThreadsQuery = """ + query inboxListThreads($rid: ID!) { + list(rid: $rid) { + threads { + results { + updated + subject + } + } + } + } + """ + init(currentUser: User, client: SRHTClient) { self.currentUser = currentUser self.client = client + self.projectService = ProjectService(client: client) } func loadDashboard() async { + isLoadingProjects = true isLoadingFailedBuilds = true isLoadingAssignedTickets = true isLoadingRecentBuilds = true @@ -190,8 +259,19 @@ final class HomeViewModel { assignedTicketsError = nil recentBuildsError = nil + async let projectsTask = loadProjects() async let jobsTask = loadRecentJobs() async let assignedTicketsTask = loadAssignedTickets() + async let inboxUnreadTask = loadInboxUnreadState() + + let projectsResult = await projectsTask + switch projectsResult { + case .success(let projects): + self.projects = projects + case .failure: + self.projects = [] + } + isLoadingProjects = false let recentJobsResult = await jobsTask @@ -222,6 +302,16 @@ final class HomeViewModel { self.assignedTicketsError = error.localizedDescription } isLoadingAssignedTickets = false + + hasUnreadInboxThreads = (await inboxUnreadTask) ?? false + } + + private func loadProjects() async -> Result<[Project], Error> { + do { + return .success(try await projectService.fetchProjects()) + } catch { + return .failure(error) + } } private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { @@ -237,6 +327,100 @@ final class HomeViewModel { } } + private func loadInboxUnreadState() async -> Bool? { + do { + return try await fetchHasUnreadInboxThreads() + } catch { + return nil + } + } + + private func fetchHasUnreadInboxThreads() async throws -> Bool { + let mailingLists = try await fetchInboxMailingLists() + guard !mailingLists.isEmpty else { return false } + + var startIndex = mailingLists.startIndex + while startIndex < mailingLists.endIndex { + let endIndex = mailingLists.index( + startIndex, + offsetBy: inboxUnreadConcurrencyLimit, + limitedBy: mailingLists.endIndex + ) ?? mailingLists.endIndex + let batch = Array(mailingLists[startIndex..<endIndex]) + + let batchHasUnread = await withTaskGroup(of: Bool.self) { group in + for mailingList in batch { + group.addTask { + (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false + } + } + + for await hasUnread in group { + if hasUnread { + group.cancelAll() + return true + } + } + return false + } + + if batchHasUnread { + return true + } + + startIndex = endIndex + } + + return false + } + + private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { + var subscriptions: [HomeInboxSubscription] = [] + var cursor: String? + + while true { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .lists, + query: Self.inboxSubscriptionsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: HomeInboxSubscriptionsResponse.self + ) + + subscriptions.append(contentsOf: response.subscriptions.results) + guard let nextCursor = response.subscriptions.cursor else { + break + } + cursor = nextCursor + } + + var seen = Set<String>() + return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } + } + + private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool { + let response = try await client.execute( + service: .lists, + query: Self.inboxListThreadsQuery, + variables: ["rid": mailingList.rid], + responseType: HomeInboxListThreadsResponse.self + ) + + return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in + let normalizedSubject = thread.subject + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) + .lowercased() + let threadID = "\(mailingList.rid)#\(normalizedSubject)" + return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) + } + } + private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { do { let trackers = try await fetchAllTrackers() diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift index 602f8d9..304e62d 100644 --- a/Hutch/Views/Inbox/InboxView.swift +++ b/Hutch/Views/Inbox/InboxView.swift @@ -51,9 +51,9 @@ struct InboxView: View { ) } else if viewModel.threads.isEmpty, viewModel.error == nil { ContentUnavailableView( - "No Threads", + "Inbox Zero", systemImage: "tray", - description: Text("Patch threads will appear here.") + description: Text("Unread threads will appear here.") ) } } @@ -75,19 +75,16 @@ struct InboxView: View { private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { Button { withAnimation(.easeInOut(duration: 0.2)) { - viewModel.toggleThreadReadState(thread) + viewModel.markThreadRead(thread) } } label: { - Label( - thread.isUnread ? "Mark as Read" : "Mark as Unread", - systemImage: thread.isUnread ? "envelope.open" : "envelope.badge" - ) + Label("Mark as Read", systemImage: "envelope.open") } - .tint(thread.isUnread ? .blue : .gray) + .tint(.blue) } } -private struct InboxThreadRow: View { +struct InboxThreadRow: View { let thread: InboxThreadSummary var body: some View { diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift index 9211b40..9c1ef45 100644 --- a/Hutch/Views/Inbox/InboxViewModel.swift +++ b/Hutch/Views/Inbox/InboxViewModel.swift @@ -127,7 +127,9 @@ final class InboxViewModel { let subscriptions = try await fetchSubscriptions() let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) let fetchedThreads = try await fetchThreads(for: mailingLists) - threads = fetchedThreads.sorted { lhs, rhs in + threads = fetchedThreads + .filter(\.isUnread) + .sorted { lhs, rhs in if lhs.lastActivityAt == rhs.lastActivityAt { return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending } @@ -145,7 +147,7 @@ final class InboxViewModel { inboxListLogger.debug( "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)" ) - updateThread(thread, isUnread: false) + threads.removeAll { $0.id == thread.id } } func markThreadUnread(_ thread: InboxThreadSummary) { @@ -329,6 +331,10 @@ final class InboxViewModel { private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } let current = threads[index] + if !isUnread { + threads.remove(at: index) + return + } threads[index] = InboxThreadSummary( rootEmailID: current.rootEmailID, rootMessageID: current.rootMessageID, diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift index 7677698..6fe835c 100644 --- a/Hutch/Views/Inbox/ThreadDetailView.swift +++ b/Hutch/Views/Inbox/ThreadDetailView.swift @@ -8,10 +8,29 @@ private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: struct ThreadDetailView: View { let thread: InboxThreadSummary let onViewed: () -> Void + var onMarkRead: (() -> Void)? = nil + var onMarkUnread: (() -> Void)? = nil @Environment(AppState.self) private var appState @State private var viewModel: ThreadViewModel? @State private var replySuccessMessage: String? + @State private var loadedThreadID: String? + @State private var hasMarkedCurrentThreadViewed = false + @State private var suppressAutoMarkViewed = false + @State private var isUnread: Bool + + init( + thread: InboxThreadSummary, + onViewed: @escaping () -> Void, + onMarkRead: (() -> Void)? = nil, + onMarkUnread: (() -> Void)? = nil + ) { + self.thread = thread + self.onViewed = onViewed + self.onMarkRead = onMarkRead + self.onMarkUnread = onMarkUnread + self._isUnread = State(initialValue: thread.isUnread) + } var body: some View { Group { @@ -23,13 +42,21 @@ struct ThreadDetailView: View { } .navigationTitle("Thread") .navigationBarTitleDisplayMode(.inline) - .task { - if viewModel == nil { - onViewed() - let vm = ThreadViewModel(summary: thread, client: appState.client) - viewModel = vm - await vm.loadThread() - } + .task(id: thread.id) { + guard loadedThreadID != thread.id else { return } + let vm = ThreadViewModel(summary: thread, client: appState.client) + viewModel = vm + loadedThreadID = thread.id + hasMarkedCurrentThreadViewed = false + suppressAutoMarkViewed = false + isUnread = thread.isUnread + await vm.loadThread() + } + .onChange(of: viewModel?.thread?.id) { _, threadID in + guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return } + hasMarkedCurrentThreadViewed = true + isUnread = false + onViewed() } .sheet(item: Binding( get: { viewModel?.composeDraft }, @@ -109,8 +136,23 @@ struct ThreadDetailView: View { .listStyle(.plain) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Reply") { - viewModel.prepareReply() + HStack { + if onMarkRead != nil || onMarkUnread != nil { + Button(isUnread ? "Mark Read" : "Mark Unread") { + suppressAutoMarkViewed = !isUnread + if isUnread { + onMarkRead?() + isUnread = false + } else { + onMarkUnread?() + isUnread = true + } + } + } + + Button("Reply") { + viewModel.prepareReply() + } } } } diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift new file mode 100644 index 0000000..80d4e50 --- /dev/null +++ b/Hutch/Views/Lists/MailingListListView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +@Observable +@MainActor +final class MailingListListViewModel { + private(set) var mailingLists: [InboxMailingListReference] = [] + private(set) var isLoading = false + var error: String? + + private let client: SRHTClient + + private static let subscriptionsQuery = """ + query mailingLists($cursor: Cursor) { + subscriptions(cursor: $cursor) { + results { + ... on MailingListSubscription { + list { + id + rid + name + owner { canonicalName } + } + } + } + cursor + } + } + """ + + init(client: SRHTClient) { + self.client = client + } + + func loadMailingLists() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + mailingLists = try await fetchMailingLists() + } catch { + self.error = "Failed to load mailing lists" + } + } + + private func fetchMailingLists() async throws -> [InboxMailingListReference] { + struct Response: Decodable, Sendable { + let subscriptions: Page + } + + struct Page: Decodable, Sendable { + let results: [Subscription] + let cursor: String? + } + + struct Subscription: Decodable, Sendable { + let list: InboxMailingListReference? + } + + var results: [InboxMailingListReference] = [] + var cursor: String? + + while true { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .lists, + query: Self.subscriptionsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: Response.self + ) + + results.append(contentsOf: response.subscriptions.results.compactMap(\.list)) + guard let nextCursor = response.subscriptions.cursor else { + break + } + cursor = nextCursor + } + + var seen = Set<String>() + return results + .filter { seen.insert($0.rid).inserted } + .sorted { + if $0.owner.canonicalName == $1.owner.canonicalName { + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending + } + } +} + +struct MailingListListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: MailingListListViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading mailing lists…") + } + } + .navigationTitle("Lists") + .task { + if viewModel == nil { + let vm = MailingListListViewModel(client: appState.client) + viewModel = vm + await vm.loadMailingLists() + } + } + } + + @ViewBuilder + private func content(_ viewModel: MailingListListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.mailingLists, id: \.rid) { mailingList in + NavigationLink(value: MoreRoute.mailingList(mailingList)) { + VStack(alignment: .leading, spacing: 4) { + Text(mailingList.name) + .font(.subheadline.weight(.medium)) + Text(mailingList.owner.canonicalName) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.mailingLists.isEmpty { + SRHTLoadingStateView(message: "Loading mailing lists…") + } else if let error = viewModel.error, viewModel.mailingLists.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Mailing Lists", + message: error, + retryAction: { await viewModel.loadMailingLists() } + ) + } else if viewModel.mailingLists.isEmpty { + ContentUnavailableView( + "No Mailing Lists", + systemImage: "list.bullet.rectangle", + description: Text("Your subscribed mailing lists will appear here.") + ) + } + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadMailingLists() + } + } +} diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift new file mode 100644 index 0000000..b7c5576 --- /dev/null +++ b/Hutch/Views/More/MoreView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct MoreView: View { + private let unsupportedLinks: [(title: String, url: URL)] = [ + ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), + ("man.sr.ht", URL(string: "https://man.sr.ht")!), + ("srht.site", URL(string: "https://srht.site")!) + ] + + var body: some View { + List { + Section { + NavigationLink(value: MoreRoute.lists) { + Label("Lists", systemImage: "list.bullet.rectangle") + } + + NavigationLink(value: MoreRoute.pastes) { + Label("Pastes", systemImage: "doc.on.clipboard") + } + + NavigationLink(value: MoreRoute.settings) { + Label("Settings", systemImage: "gear") + } + } + + Section { + ForEach(unsupportedLinks, id: \.title) { item in + Link(destination: item.url) { + Label(item.title, systemImage: "safari") + } + } + } header: { + Text("External Links") + } footer: { + Text("These SourceHut services are not supported in-app and open in your browser.") + } + } + .navigationTitle("More") + } +} diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift new file mode 100644 index 0000000..df3a8be --- /dev/null +++ b/Hutch/Views/Pastes/PasteDetailView.swift @@ -0,0 +1,288 @@ +import SwiftUI + +struct PasteDetailView: View { + let paste: Paste + var onUpdated: ((Paste) -> Void)? = nil + var onDeleted: ((String) -> Void)? = nil + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var viewModel: PasteDetailViewModel? + @State private var showVisibilitySheet = false + @State private var showDeleteConfirmation = false + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading paste…") + } + } + .navigationTitle(displayTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + SRHTShareButton( + url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) }, + target: .paste + ) { + Image(systemName: "square.and.arrow.up") + } + + if viewModel != nil { + Menu { + Button { + showVisibilitySheet = true + } label: { + Label("Change Visibility", systemImage: "eye") + } + + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + Label("Delete Paste", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + .sheet(isPresented: $showVisibilitySheet) { + if let viewModel, let currentPaste { + PasteVisibilitySheet( + currentVisibility: currentPaste.visibility, + isUpdating: viewModel.isUpdatingVisibility + ) { visibility in + if let updated = await viewModel.updateVisibility(visibility) { + onUpdated?(updated) + showVisibilitySheet = false + } + } + } + } + .alert("Delete Paste?", isPresented: $showDeleteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { + if await viewModel?.deletePaste() == true { + onDeleted?(paste.id) + dismiss() + } + } + } + } message: { + Text("This paste will be permanently removed.") + } + .task { + if viewModel == nil { + let vm = PasteDetailViewModel( + pasteID: paste.id, + initialPaste: paste, + service: PasteService(client: appState.client) + ) + viewModel = vm + await vm.loadPaste() + } + } + } + + private var currentPaste: Paste? { + viewModel?.paste ?? paste + } + + private var displayTitle: String { + if let filename = currentPaste?.files.first?.filename, !filename.isEmpty { + return filename + } + return "Paste \(paste.id)" + } + + @ViewBuilder + private func content(_ viewModel: PasteDetailViewModel) -> some View { + @Bindable var vm = viewModel + + if viewModel.isLoading, viewModel.paste == nil { + SRHTLoadingStateView(message: "Loading paste…") + } else if let error = viewModel.error, viewModel.paste == nil { + SRHTErrorStateView( + title: "Couldn't Load Paste", + message: error, + retryAction: { await viewModel.loadPaste() } + ) + } else if let paste = viewModel.paste { + List { + Section("Details") { + LabeledContent("ID", value: paste.id) + LabeledContent("Owner", value: paste.user.canonicalName) + LabeledContent("Created", value: paste.created.relativeDescription) + LabeledContent("Visibility", value: visibilityLabel(paste.visibility)) + LabeledContent("Files", value: "\(paste.files.count)") + } + + if paste.files.count > 1 { + Section("Files") { + Picker("Selected File", selection: Binding( + get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" }, + set: { viewModel.selectFile(hash: $0) } + )) { + ForEach(paste.files) { file in + Text(file.filename ?? String(file.hash.prefix(8))) + .tag(file.hash) + } + } + } + } + + if let file = viewModel.selectedFile { + Section("Current File") { + if let filename = file.filename, !filename.isEmpty { + LabeledContent("Filename", value: filename) + } + LabeledContent("Hash", value: file.hash) + } + + Section { + if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil { + SRHTLoadingStateView(message: "Loading paste contents…") + .frame(minHeight: 180) + } else if let contents = viewModel.selectedFileContents { + PasteCodeBlock(text: contents) + } else { + Text("This file’s contents are unavailable.") + .foregroundStyle(.secondary) + } + } header: { + Text("Contents") + } + } + } + .listStyle(.insetGrouped) + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadPaste() + } + } + } + + private func visibilityLabel(_ visibility: Visibility) -> String { + switch visibility { + case .public: + return "Public" + case .unlisted: + return "Unlisted" + case .private: + return "Private" + } + } +} + +private struct PasteCodeBlock: View { + let text: String + + var body: some View { + ScrollView([.horizontal, .vertical], showsIndicators: true) { + Text(text.isEmpty ? " " : text) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + .frame(minHeight: 220) + } +} + +private struct PasteVisibilitySheet: View { + let currentVisibility: Visibility + let isUpdating: Bool + let onSave: (Visibility) async -> Void + + @Environment(\.dismiss) private var dismiss + @State private var visibility: Visibility + + init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) { + self.currentVisibility = currentVisibility + self.isUpdating = isUpdating + self.onSave = onSave + _visibility = State(initialValue: currentVisibility) + } + + var body: some View { + NavigationStack { + List { + ForEach(visibilityOptions, id: \.self) { option in + Button { + visibility = option + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title(for: option)) + .foregroundStyle(.primary) + Text(description(for: option)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if visibility == option { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .listStyle(.insetGrouped) + .navigationTitle("Visibility") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { + await onSave(visibility) + } + } + .disabled(isUpdating || visibility == currentVisibility) + } + } + .overlay { + if isUpdating { + ProgressView() + } + } + } + } + + private var visibilityOptions: [Visibility] { + [.public, .unlisted, .private] + } + + private func title(for visibility: Visibility) -> String { + switch visibility { + case .public: + "Public" + case .unlisted: + "Unlisted" + case .private: + "Private" + } + } + + private func description(for visibility: Visibility) -> String { + switch visibility { + case .public: + "Visible to everyone and listed on your profile." + case .unlisted: + "Visible to anyone with the URL, but not listed on your profile." + case .private: + "Visible only to explicitly allowed viewers." + } + } +} diff --git a/Hutch/Views/Pastes/PasteDetailViewModel.swift b/Hutch/Views/Pastes/PasteDetailViewModel.swift new file mode 100644 index 0000000..8c60edd --- /dev/null +++ b/Hutch/Views/Pastes/PasteDetailViewModel.swift @@ -0,0 +1,114 @@ +import Foundation + +@Observable +@MainActor +final class PasteDetailViewModel { + private(set) var paste: Paste? + private(set) var isLoading = false + private(set) var isUpdatingVisibility = false + private(set) var isDeleting = false + private(set) var loadingFileHashes: Set<String> = [] + var error: String? + + var selectedFileHash: String? + private(set) var fileContents: [String: String] = [:] + + private let pasteID: String + private let service: PasteService + + init(pasteID: String, initialPaste: Paste? = nil, service: PasteService) { + self.pasteID = pasteID + self.paste = initialPaste + self.service = service + self.selectedFileHash = initialPaste?.files.first?.hash + } + + var selectedFile: PasteFile? { + let hash = selectedFileHash ?? paste?.files.first?.hash + return paste?.files.first(where: { $0.hash == hash }) + } + + var selectedFileContents: String? { + guard let selectedFile else { return nil } + return fileContents[selectedFile.hash] + } + + func loadPaste() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + let loaded = try await service.loadPaste(id: pasteID) + paste = loaded + if selectedFileHash == nil { + selectedFileHash = loaded?.files.first?.hash + } + await loadSelectedFileContentsIfNeeded() + } catch { + self.error = error.localizedDescription + } + } + + func selectFile(hash: String) { + selectedFileHash = hash + Task { + await loadSelectedFileContentsIfNeeded() + } + } + + func updateVisibility(_ visibility: Visibility) async -> Paste? { + guard !isUpdatingVisibility else { return nil } + guard let paste else { return nil } + guard paste.visibility != visibility else { return paste } + + isUpdatingVisibility = true + error = nil + defer { isUpdatingVisibility = false } + + do { + let updatedPaste = try await service.updateVisibility(id: paste.id, visibility: visibility) + if let updatedPaste { + self.paste = updatedPaste + if selectedFileHash == nil { + selectedFileHash = updatedPaste.files.first?.hash + } + } + return updatedPaste + } catch { + self.error = error.localizedDescription + return nil + } + } + + func deletePaste() async -> Bool { + guard !isDeleting else { return false } + isDeleting = true + error = nil + defer { isDeleting = false } + + do { + _ = try await service.deletePaste(id: pasteID) + return true + } catch { + self.error = error.localizedDescription + return false + } + } + + func loadSelectedFileContentsIfNeeded() async { + guard let file = selectedFile, fileContents[file.hash] == nil else { return } + guard let url = file.contents else { return } + guard !loadingFileHashes.contains(file.hash) else { return } + + loadingFileHashes.insert(file.hash) + defer { loadingFileHashes.remove(file.hash) } + + do { + fileContents[file.hash] = try await service.loadContents(from: url) + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift new file mode 100644 index 0000000..9d8c0d4 --- /dev/null +++ b/Hutch/Views/Pastes/PasteListView.swift @@ -0,0 +1,280 @@ +import SwiftUI + +struct PasteListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: PasteListViewModel? + @State private var showCreatePasteSheet = false + @State private var createdPaste: Paste? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading pastes…") + } + } + .navigationTitle("Pastes") + .toolbar { + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreatePasteSheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showCreatePasteSheet) { + if let viewModel { + CreatePasteSheet(viewModel: viewModel) { paste in + showCreatePasteSheet = false + createdPaste = paste + } + } + } + .navigationDestination(isPresented: Binding( + get: { createdPaste != nil }, + set: { isPresented in + if !isPresented { + createdPaste = nil + } + } + )) { + if let createdPaste { + PasteDetailView( + paste: createdPaste, + onUpdated: { updated in + viewModel?.upsertPaste(updated) + }, + onDeleted: { id in + viewModel?.removePaste(id: id) + } + ) + } + } + .task { + if viewModel == nil { + let vm = PasteListViewModel(service: PasteService(client: appState.client)) + viewModel = vm + await vm.loadPastes() + } + } + } + + @ViewBuilder + private func content(_ viewModel: PasteListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.pastes) { paste in + NavigationLink(value: paste) { + PasteRowView(paste: paste) + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: paste) + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.pastes.isEmpty { + SRHTLoadingStateView(message: "Loading pastes…") + } else if let error = viewModel.error, viewModel.pastes.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Pastes", + message: error, + retryAction: { await viewModel.loadPastes() } + ) + } else if viewModel.pastes.isEmpty { + ContentUnavailableView( + "No Pastes", + systemImage: "doc.on.clipboard", + description: Text("Your pastes will appear here.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) { + await viewModel.loadPastes() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadPastes() + } + .navigationDestination(for: Paste.self) { paste in + PasteDetailView( + paste: paste, + onUpdated: { updated in + viewModel.upsertPaste(updated) + }, + onDeleted: { id in + viewModel.removePaste(id: id) + } + ) + } + } +} + +private struct PasteRowView: View { + let paste: Paste + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "doc.text") + .foregroundStyle(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(primaryTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + Text(secondaryLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack(spacing: 8) { + VisibilityBadge(visibility: paste.visibility) + Text("•") + .foregroundStyle(.tertiary) + Text(paste.created.relativeDescription) + .foregroundStyle(.tertiary) + } + .font(.caption2) + } + } + .padding(.vertical, 2) + } + + private var primaryTitle: String { + if let filename = paste.files.first?.filename, !filename.isEmpty { + return filename + } + return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste" + } + + private var secondaryLine: String { + var parts: [String] = [paste.user.canonicalName] + if paste.files.count > 1 { + parts.append("\(paste.files.count) files") + } else { + parts.append("1 file") + } + if let firstHash = paste.files.first?.hash { + parts.append(String(firstHash.prefix(8))) + } + return parts.joined(separator: " • ") + } +} + +private struct CreatePasteSheet: View { + let viewModel: PasteListViewModel + let onCreated: (Paste) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var files = [PasteUploadDraft()] + @State private var visibility: Visibility = .unlisted + + var body: some View { + NavigationStack { + Form { + Section("Files") { + ForEach($files) { $file in + VStack(alignment: .leading, spacing: 8) { + TextField("Filename (optional)", text: $file.filename) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + ZStack(alignment: .topLeading) { + if file.contents.isEmpty { + Text("Paste contents") + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 5) + .allowsHitTesting(false) + } + + TextEditor(text: $file.contents) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 180) + } + } + .padding(.vertical, 4) + } + .onDelete { offsets in + files.remove(atOffsets: offsets) + if files.isEmpty { + files = [PasteUploadDraft()] + } + } + + Button { + files.append(PasteUploadDraft()) + } label: { + Label("Add File", systemImage: "plus") + } + } + + Section("Visibility") { + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + } + + Section { + 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.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let error = viewModel.error { + Section { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + } + .navigationTitle("New Paste") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if let paste = await viewModel.createPaste(files: files, visibility: visibility) { + onCreated(paste) + } + } + } label: { + if viewModel.isCreatingPaste { + ProgressView() + .controlSize(.small) + } else { + Text("Create Paste") + } + } + .disabled(!hasValidContent || viewModel.isCreatingPaste) + } + } + } + } + + private var hasValidContent: Bool { + files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } +} diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift new file mode 100644 index 0000000..ee7d47a --- /dev/null +++ b/Hutch/Views/Pastes/PasteListViewModel.swift @@ -0,0 +1,109 @@ +import Foundation + +@Observable +@MainActor +final class PasteListViewModel { + private(set) var pastes: [Paste] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var isRefreshing = false + private(set) var isCreatingPaste = false + var error: String? + + private var cursor: String? + private var hasMore = true + private let service: PasteService + + init(service: PasteService) { + self.service = service + } + + func loadPastes() async { + if pastes.isEmpty, let cached = service.loadCachedPastes() { + pastes = cached.results + cursor = cached.cursor + hasMore = cached.cursor != nil + } + + if pastes.isEmpty { + isLoading = true + } else { + isRefreshing = true + } + error = nil + cursor = nil + hasMore = true + + do { + let page = try await service.listPastes(cursor: nil, useCache: true) + pastes = page.results + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + if pastes.isEmpty { + self.error = error.localizedDescription + } + } + + isLoading = false + isRefreshing = false + } + + func loadMoreIfNeeded(currentItem: Paste) async { + guard let last = pastes.last, + last.id == currentItem.id, + hasMore, + !isLoadingMore else { + return + } + + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let page = try await service.listPastes(cursor: cursor, useCache: false) + pastes.append(contentsOf: page.results) + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + } + + func createPaste(files: [PasteUploadDraft], visibility: Visibility) async -> Paste? { + guard !isCreatingPaste else { return nil } + + let normalizedFiles = files.filter { + !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + guard !normalizedFiles.isEmpty else { + error = "Add at least one file with text content." + return nil + } + + isCreatingPaste = true + error = nil + defer { isCreatingPaste = false } + + do { + let paste = try await service.createPaste(files: normalizedFiles, visibility: visibility) + upsertPaste(paste) + return paste + } catch { + self.error = error.localizedDescription + return nil + } + } + + func upsertPaste(_ paste: Paste) { + if let index = pastes.firstIndex(where: { $0.id == paste.id }) { + pastes[index] = paste + } else { + pastes.insert(paste, at: 0) + } + } + + func removePaste(id: String) { + pastes.removeAll { $0.id == id } + } +} diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift new file mode 100644 index 0000000..89601a9 --- /dev/null +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -0,0 +1,135 @@ +import SwiftUI + +struct ProjectDetailView: View { + let project: Project + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + var body: some View { + List { + headerSection + repositoriesSection + trackersSection + mailingListsSection + } + .navigationTitle(project.name) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var headerSection: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(project.name) + .font(.headline) + + if let description = project.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if let website = project.website, let url = URL(string: website) { + Link(destination: url) { + Label(website, systemImage: "link") + .font(.subheadline) + } + } + } + .padding(.vertical, 4) + } + } + + @ViewBuilder + private var repositoriesSection: some View { + if !project.sources.isEmpty { + Section("Repositories") { + ForEach(project.sources) { source in + Button { + Task { + try? await appState.openProjectSource(source) + dismiss() + } + } label: { + ProjectResourceRow( + title: source.name, + subtitle: source.owner.canonicalName, + detail: source.description + ) + } + .buttonStyle(.plain) + } + } + } + } + + @ViewBuilder + private var trackersSection: some View { + if !project.trackers.isEmpty { + Section("Trackers") { + ForEach(project.trackers) { tracker in + Button { + Task { + try? await appState.openProjectTracker(tracker) + dismiss() + } + } label: { + ProjectResourceRow( + title: tracker.name, + subtitle: tracker.owner.canonicalName, + detail: tracker.description + ) + } + .buttonStyle(.plain) + } + } + } + } + + @ViewBuilder + private var mailingListsSection: some View { + if !project.mailingLists.isEmpty { + Section("Mailing Lists") { + ForEach(project.mailingLists) { mailingList in + Button { + appState.openMailingList(mailingList.inboxReference) + dismiss() + } label: { + ProjectResourceRow( + title: mailingList.name, + subtitle: mailingList.owner.canonicalName, + detail: mailingList.description + ) + } + .buttonStyle(.plain) + } + } + } + } +} + +private struct ProjectResourceRow: View { + let title: String + let subtitle: String + let detail: String? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let detail, !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + } + .padding(.vertical, 2) + } +} diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift new file mode 100644 index 0000000..df0ffe1 --- /dev/null +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -0,0 +1,282 @@ +import SwiftUI + +private struct ProjectMailingListThreadsResponse: Decodable, Sendable { + let list: ProjectMailingListThreads +} + +private struct ProjectMailingListThreads: Decodable, Sendable { + let threads: ProjectMailingListThreadPage +} + +private struct ProjectMailingListThreadPage: Decodable, Sendable { + let results: [ProjectMailingListThreadPayload] +} + +private struct ProjectMailingListThreadPayload: Decodable, Sendable { + let updated: Date + let subject: String + let replies: Int + let sender: Entity + let root: ProjectMailingListRootPayload +} + +private struct ProjectMailingListRootPayload: Decodable, Sendable { + let id: Int + let messageID: String + let patch: InboxPatchPreview? +} + +@Observable +@MainActor +final class MailingListDetailViewModel { + private(set) var threads: [InboxThreadSummary] = [] + private(set) var isLoading = false + var error: String? + + private let mailingList: InboxMailingListReference + private let client: SRHTClient + + private static let listThreadsQuery = """ + query projectMailingListThreads($rid: ID!) { + list(rid: $rid) { + threads { + results { + updated + subject + replies + sender { canonicalName } + root { + id + messageID + patch { subject } + } + } + } + } + } + """ + + init(mailingList: InboxMailingListReference, client: SRHTClient) { + self.mailingList = mailingList + self.client = client + } + + func loadThreads() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + let response = try await client.execute( + service: .lists, + query: Self.listThreadsQuery, + variables: ["rid": mailingList.rid], + responseType: ProjectMailingListThreadsResponse.self + ) + + threads = deduplicateThreads( + response.list.threads.results.map(makeSummary(from:)) + ) + } catch { + self.error = "Failed to load mailing list" + } + } + + func markThreadRead(_ thread: InboxThreadSummary) { + let viewedAt = max(Date(), thread.lastActivityAt) + InboxReadStateStore.markViewed(viewedAt, for: thread.id) + updateThread(thread, isUnread: false) + } + + func markThreadUnread(_ thread: InboxThreadSummary) { + InboxReadStateStore.markUnread(for: thread.id) + updateThread(thread, isUnread: true) + } + + private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { + let normalizedSubject = thread.subject + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) + .lowercased() + let threadID = "\(mailingList.rid)#\(normalizedSubject)" + + return InboxThreadSummary( + rootEmailID: thread.root.id, + rootMessageID: thread.root.messageID, + threadRootEmailIDs: [thread.root.id], + threadRootMessageIDs: [thread.root.messageID], + listID: 0, + listRID: mailingList.rid, + listName: mailingList.name, + listOwner: mailingList.owner, + subject: thread.subject, + latestSender: thread.sender, + lastActivityAt: thread.updated, + messageCount: thread.replies + 1, + repo: nil, + containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), + isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) + ) + } + + private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { + guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } + let current = threads[index] + threads[index] = InboxThreadSummary( + rootEmailID: current.rootEmailID, + rootMessageID: current.rootMessageID, + threadRootEmailIDs: current.threadRootEmailIDs, + threadRootMessageIDs: current.threadRootMessageIDs, + listID: current.listID, + listRID: current.listRID, + listName: current.listName, + listOwner: current.listOwner, + subject: current.subject, + latestSender: current.latestSender, + lastActivityAt: current.lastActivityAt, + messageCount: current.messageCount, + repo: current.repo, + containsPatch: current.containsPatch, + isUnread: isUnread + ) + } + + private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] { + var grouped: [String: InboxThreadSummary] = [:] + + for thread in threads { + guard let existing = grouped[thread.threadGroupingKey] else { + grouped[thread.threadGroupingKey] = thread + continue + } + + let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing + let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted() + let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted() + let mergedMessageCount = max( + existing.messageCount ?? existing.threadRootMessageIDs.count, + thread.messageCount ?? thread.threadRootMessageIDs.count, + mergedRootMessageIDs.count + ) + + grouped[thread.threadGroupingKey] = InboxThreadSummary( + rootEmailID: latest.rootEmailID, + rootMessageID: latest.rootMessageID, + threadRootEmailIDs: mergedRootEmailIDs, + threadRootMessageIDs: mergedRootMessageIDs, + listID: latest.listID, + listRID: latest.listRID, + listName: latest.listName, + listOwner: latest.listOwner, + subject: latest.subject, + latestSender: latest.latestSender, + lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt), + messageCount: mergedMessageCount, + repo: latest.repo ?? existing.repo, + containsPatch: latest.containsPatch || existing.containsPatch, + isUnread: latest.isUnread || existing.isUnread + ) + } + + return grouped.values.sorted { lhs, rhs in + if lhs.lastActivityAt == rhs.lastActivityAt { + return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending + } + return lhs.lastActivityAt > rhs.lastActivityAt + } + } +} + +struct MailingListDetailView: View { + let mailingList: InboxMailingListReference + + @Environment(AppState.self) private var appState + @State private var viewModel: MailingListDetailViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading mailing list…") + } + } + .navigationTitle(mailingList.name) + .navigationBarTitleDisplayMode(.inline) + .task { + if viewModel == nil { + let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client) + self.viewModel = viewModel + await viewModel.loadThreads() + } + } + .onAppear { + guard let viewModel else { return } + Task { + await viewModel.loadThreads() + } + } + } + + @ViewBuilder + private func content(_ viewModel: MailingListDetailViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.threads) { thread in + NavigationLink(value: MoreRoute.thread(thread)) { + InboxThreadRow(thread: thread) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + if thread.isUnread { + viewModel.markThreadRead(thread) + } else { + viewModel.markThreadUnread(thread) + } + } + } label: { + Label( + thread.isUnread ? "Mark as Read" : "Mark as Unread", + systemImage: thread.isUnread ? "envelope.open" : "envelope.badge" + ) + } + .tint(thread.isUnread ? .blue : .gray) + } + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.threads.isEmpty { + SRHTLoadingStateView(message: "Loading mailing list…") + } else if let error = viewModel.error, viewModel.threads.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Mailing List", + message: error, + retryAction: { await viewModel.loadThreads() } + ) + } else if viewModel.threads.isEmpty { + ContentUnavailableView( + "No Threads", + systemImage: "tray", + description: Text("This mailing list does not have any recent threads.") + ) + } + } + .refreshable { + await viewModel.loadThreads() + } + .srhtErrorBanner(error: $vm.error) + } +} + +struct ProjectMailingListView: View { + let mailingList: Project.MailingList + + var body: some View { + MailingListDetailView(mailingList: mailingList.inboxReference) + } +} diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift index 9b7d41f..91aa420 100644 --- a/Hutch/Views/Settings/SettingsView.swift +++ b/Hutch/Views/Settings/SettingsView.swift @@ -12,21 +12,19 @@ struct SettingsView: View { @State private var pendingDestructiveAction: SettingsDestructiveAction? var body: some View { - NavigationStack { - Group { - if let viewModel { - settingsContent(viewModel) - } else { - SRHTLoadingStateView(message: "Loading profile…") - } + Group { + if let viewModel { + settingsContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading profile…") } - .navigationTitle("Settings") - .task { - if viewModel == nil { - let vm = SettingsViewModel(client: appState.client) - viewModel = vm - await vm.loadProfile() - } + } + .navigationTitle("Settings") + .task { + if viewModel == nil { + let vm = SettingsViewModel(client: appState.client) + viewModel = vm + await vm.loadProfile() } } } |
