diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 15:18:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 15:18:38 -0500 |
| commit | 659c76df9ff3926cb68886c16167808c23bd8f75 (patch) | |
| tree | 572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch | |
| parent | ed48e72520c51e4635cd3840dbc569bdd74c950f (diff) | |
| parent | 6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (diff) | |
| download | hutch-2.tar.gz hutch-2.tar.bz2 hutch-2.zip | |
v2.0: Merge branch 'dev'v2
Diffstat (limited to 'Hutch')
34 files changed, 5745 insertions, 60 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 624c78d..d5a86fd 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -7,6 +7,20 @@ import WebKit @MainActor final class AppState { + enum Tab: Hashable { + case home + case repositories + case tickets + case builds + case more + } + + enum TabNavigationTarget: Hashable { + case repository(RepositorySummary) + case tracker(TrackerSummary) + case mailingList(InboxMailingListReference) + } + enum AuthPhase { /// App just launched, checking for an existing token. case launching @@ -25,6 +39,8 @@ final class AppState { authPhase == .authenticated && currentUser != nil } + var selectedTab: Tab = .home + // MARK: - Current user (populated after successful validation) private(set) var currentUser: User? @@ -37,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 @@ -94,6 +111,7 @@ final class AppState { await clearWebData() clearWebContentRenderCaches() authPhase = .unauthenticated + selectedTab = .home } func resetAppData() async { @@ -108,14 +126,15 @@ final class AppState { clearWebContentRenderCaches() authPhase = .unauthenticated + selectedTab = .home } // 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 @@ -134,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 = """ @@ -208,6 +256,8 @@ final class AppState { client.responseCache.clear() currentUser = nil pendingDeepLink = nil + pendingTabNavigation = nil + selectedTab = .home } private func clearWebData() async { diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 7af162d..0885835 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -4,15 +4,8 @@ import SwiftUI /// full-screen sheet for token entry on first launch. struct RootView: View { @Environment(AppState.self) private var appState - - enum Tab: Hashable { - case repositories - case builds - case tickets - case settings - } - - @State private var selectedTab: Tab = .repositories + @State private var homePath = NavigationPath() + @State private var morePath = NavigationPath() @State private var repoPath = NavigationPath() @State private var buildsPath = NavigationPath() @State private var ticketsPath = NavigationPath() @@ -41,20 +34,45 @@ struct RootView: View { .onChange(of: appState.authPhase) { _, newPhase in handleAuthPhaseChange(newPhase) } + .onChange(of: appState.pendingTabNavigation) { _, newValue in + consumePendingTabNavigationIfPossible(newValue) + } } // MARK: - Tab View private var tabContent: some View { - TabView(selection: $selectedTab) { + @Bindable var appState = appState + + return TabView(selection: $appState.selectedTab) { + NavigationStack(path: $homePath) { + HomeView() + } + .tag(AppState.Tab.home) + .tabItem { + Label("Home", systemImage: "house") + } + NavigationStack(path: $repoPath) { RepositoryListView() } - .tag(Tab.repositories) + .tag(AppState.Tab.repositories) .tabItem { Label("Repositories", systemImage: "book.closed") } + NavigationStack(path: $ticketsPath) { + TrackerListView() + // Deep link destination for jumping straight to a ticket. + .navigationDestination(for: TicketDeepLinkTarget.self) { target in + TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) + } + } + .tag(AppState.Tab.tickets) + .tabItem { + Label("Tickets", systemImage: "ticket") + } + NavigationStack(path: $buildsPath) { BuildListView() // Int destination used by deep links (hutch://builds/<id>). @@ -63,28 +81,18 @@ struct RootView: View { BuildDetailView(jobId: jobId) } } - .tag(Tab.builds) + .tag(AppState.Tab.builds) .tabItem { Label("Builds", systemImage: "hammer") } - NavigationStack(path: $ticketsPath) { - TrackerListView() - // Deep link destination for jumping straight to a ticket. - .navigationDestination(for: TicketDeepLinkTarget.self) { target in - TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) - } + NavigationStack(path: $morePath) { + MoreNavigationRoot() } - .tag(Tab.tickets) + .tag(AppState.Tab.more) .tabItem { - Label("Tickets", systemImage: "ticket") + Label("More", systemImage: "ellipsis.circle") } - - SettingsView() - .tag(Tab.settings) - .tabItem { - Label("Settings", systemImage: "gear") - } } .overlay { if isResolvingDeepLink { @@ -106,10 +114,12 @@ struct RootView: View { case .launching: break case .unauthenticated: + homePath = NavigationPath() + morePath = NavigationPath() repoPath = NavigationPath() buildsPath = NavigationPath() ticketsPath = NavigationPath() - selectedTab = .repositories + appState.selectedTab = .home isResolvingDeepLink = false case .authenticated: consumePendingDeepLinkIfPossible(appState.pendingDeepLink) @@ -122,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 } @@ -132,7 +148,7 @@ struct RootView: View { case .build(let jobId): // Reset the builds navigation and push the detail buildsPath = NavigationPath() - selectedTab = .builds + appState.selectedTab = .builds // Defer the push slightly so the tab switch takes effect Task { @MainActor in try? await Task.sleep(for: .milliseconds(100)) @@ -144,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 { @@ -151,7 +197,7 @@ struct RootView: View { do { let summary = try await appState.resolveRepository(owner: owner, name: repo) repoPath = NavigationPath() - selectedTab = .repositories + appState.selectedTab = .repositories try? await Task.sleep(for: .milliseconds(100)) repoPath.append(summary) } catch { @@ -167,7 +213,7 @@ struct RootView: View { do { let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) ticketsPath = NavigationPath() - selectedTab = .tickets + appState.selectedTab = .tickets try? await Task.sleep(for: .milliseconds(100)) ticketsPath.append(trackerSummary) try? await Task.sleep(for: .milliseconds(100)) @@ -185,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 new file mode 100644 index 0000000..c89cb12 --- /dev/null +++ b/Hutch/Models/Inbox.swift @@ -0,0 +1,193 @@ +import Foundation + +struct InboxThreadSummary: Identifiable, Hashable, Sendable { + let rootEmailID: Int + let rootMessageID: String + let threadRootEmailIDs: [Int] + let threadRootMessageIDs: [String] + let listID: Int + let listRID: String + let listName: String + let listOwner: Entity + let subject: String + let latestSender: Entity + let lastActivityAt: Date + let messageCount: Int? + let repo: String? + let containsPatch: Bool + let isUnread: Bool + + var id: String { + threadGroupingKey + } + + var listDisplayName: String { + "\(listOwner.canonicalName)/\(listName)" + } + + var displaySubject: String { + Self.normalizedSubject(from: subject) + } + + var metadataLine: String { + var parts = [latestSenderDisplayName] + if let messageCount, messageCount > 1 { + let replyCount = max(messageCount - 1, 1) + parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")") + } + parts.append(lastActivityAt.relativeDescription) + return parts.joined(separator: " • ") + } + + var latestSenderDisplayName: String { + let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines) + if canonicalName.contains("@") { + return canonicalName + } + return canonicalName + } + + var debugIdentifierSummary: String { + "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)" + } + + var threadGroupingKey: String { + "\(listRID)#\(displaySubject.lowercased())" + } + + private static func normalizedSubject(from subject: String) -> String { + let collapsedWhitespace = subject + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"# + return collapsedWhitespace.replacingOccurrences( + of: pattern, + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } +} + +struct InboxMessage: Identifiable, Hashable, Sendable { + let id: Int + let author: Entity + let date: Date + let subject: String + let body: String + let senderDisplayName: String + let senderEmailAddress: String? + let isPatch: Bool + let contentBlocks: [InboxMessageContentBlock] + let rawMessageURL: URL? +} + +enum InboxMessageContentBlock: Hashable, Sendable { + case plainText(String) + case diff(String) +} + +struct InboxThreadDetail: Sendable { + let id: String + let rootEmailID: Int + let rootMessageID: String + let subject: String + let author: Entity + let lastActivityAt: Date + let mailto: String? + let listID: Int + let listRID: String + let listName: String + let listOwner: Entity + let messageCount: Int? + let messages: [InboxMessage] + + var listDisplayName: String { + "\(listOwner.canonicalName)/\(listName)" + } +} + +extension InboxThreadDetail { + var replyRecipient: String { + "\(listOwner.canonicalName)/\(listName)@lists.sr.ht" + } + + var replySubject: String { + subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)" + } + + var displaySubject: String { + InboxThreadSummary( + rootEmailID: rootEmailID, + rootMessageID: rootMessageID, + threadRootEmailIDs: [rootEmailID], + threadRootMessageIDs: [rootMessageID], + listID: listID, + listRID: listRID, + listName: listName, + listOwner: listOwner, + subject: subject, + latestSender: author, + lastActivityAt: lastActivityAt, + messageCount: messageCount, + repo: nil, + containsPatch: messages.contains(where: \.isPatch), + isUnread: false + ).displaySubject + } +} + +struct MailComposeDraft: Sendable { + let recipients: [String] + let ccRecipients: [String] + let subject: String + let body: String + + var id: String { + ([subject] + recipients + ccRecipients).joined(separator: "|") + } +} + +extension MailComposeDraft: Identifiable {} + +struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable { + let id: Int + let rid: String + let name: String + let owner: Entity +} + +struct InboxPatchPreview: Decodable, Sendable, Hashable { + let subject: String? +} + +enum InboxReadStateStore { + private static let key = "InboxThreadLastViewed" + + static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? { + guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval], + let timestamp = dictionary[threadID] else { + return nil + } + return Date(timeIntervalSince1970: timestamp) + } + + static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) { + var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] + dictionary[threadID] = date.timeIntervalSince1970 + defaults.set(dictionary, forKey: key) + } + + static func markUnread(for threadID: String, defaults: UserDefaults = .standard) { + var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] + dictionary.removeValue(forKey: threadID) + defaults.set(dictionary, forKey: key) + } + + static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { + guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { + return true + } + return lastActivityAt > lastViewedAt + } +} 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 new file mode 100644 index 0000000..55ec41e --- /dev/null +++ b/Hutch/Views/Home/HomeView.swift @@ -0,0 +1,419 @@ +import SwiftUI + +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 { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading Home…") + } + } + .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) + viewModel = vm + await vm.loadDashboard() + } + } + } + + @ViewBuilder + private func content(_ viewModel: HomeViewModel) -> some View { + List { + projectsSection(viewModel) + assignedTicketsSection(viewModel) + recentBuildsSection(viewModel) + } + .listStyle(.insetGrouped) + .overlay { + if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + SRHTLoadingStateView(message: "Loading Home…") + } 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", + systemImage: "checkmark.circle", + description: Text("There are no assigned tickets or recent builds right now.") + ) + } + } + .refreshable { + await viewModel.loadDashboard() + } + } + + @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 { + HomeSectionLoadingRow(label: "Loading assigned tickets") + } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "Couldn’t load assigned tickets.", + systemImage: "exclamationmark.triangle", + emphasized: true, + accessibilityHint: error + ) + } else if viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "No open tickets assigned to you.", + systemImage: "person.crop.circle.badge.checkmark" + ) + } else { + ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: ticket.ownerUsername, + trackerName: ticket.trackerName, + trackerId: ticket.trackerId, + trackerRid: ticket.trackerRid, + ticketId: ticket.ticket.id + ) + } label: { + HomeAssignedTicketRow(ticket: ticket) + } + } + } + } header: { + HomeSectionHeader("Assigned Tickets") { + HomeAssignedTicketsListView(viewModel: viewModel) + } + } + } + + @ViewBuilder + private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { + Section { + if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { + HomeSectionLoadingRow(label: "Loading recent builds") + } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { + HomeSectionMessageRow( + text: "Couldn’t load recent builds.", + systemImage: "exclamationmark.triangle", + emphasized: true, + accessibilityHint: error + ) + } else if viewModel.recentBuilds.isEmpty { + HomeSectionMessageRow( + text: "No recent builds.", + systemImage: "clock" + ) + } else { + ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in + NavigationLink { + BuildDetailView(jobId: build.job.id) + } label: { + HomeBuildRow(build: build) + } + } + } + } header: { + HomeSectionActionHeader("Recent Builds") { + appState.selectedTab = .builds + } + } + } + +} + +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 + + var body: some View { + HStack(spacing: 12) { + JobStatusIcon(status: build.job.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(primaryTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + HStack(spacing: 8) { + Text("Job #\(build.job.id)") + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.status.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + } + } + } + .padding(.vertical, 2) + } + + private var primaryTitle: String { + if let repositoryDisplayName = build.repositoryDisplayName { + return repositoryDisplayName + } + return build.job.displayLabel + } +} + +private struct HomeAssignedTicketRow: View { + let ticket: HomeAssignedTicket + + var body: some View { + HStack(alignment: .top, spacing: 12) { + TicketStatusIcon(status: ticket.ticket.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(ticket.ticket.title) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + Text(ticket.ticket.status.displayName) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + .padding(.vertical, 2) + } +} + +private struct HomeSectionLoadingRow: View { + let label: String + + var body: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(label) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct HomeSectionHeader<Destination: View>: View { + let title: String + let destination: Destination + + init(_ title: String, @ViewBuilder destination: () -> Destination) { + self.title = title + self.destination = destination() + } + + var body: some View { + HStack { + Text(title) + Spacer() + NavigationLink { + destination + } label: { + Text("See All") + .font(.caption.weight(.medium)) + } + .buttonStyle(.plain) + } + .textCase(nil) + } +} + +private struct HomeSectionActionHeader: View { + let title: String + let action: () -> Void + + init(_ title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + HStack { + Text(title) + Spacer() + Button("See All", action: action) + .font(.caption.weight(.medium)) + .buttonStyle(.plain) + } + .textCase(nil) + } +} + +private struct HomeAssignedTicketsListView: View { + let viewModel: HomeViewModel + + var body: some View { + List { + ForEach(viewModel.assignedTickets) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: ticket.ownerUsername, + trackerName: ticket.trackerName, + trackerId: ticket.trackerId, + trackerRid: ticket.trackerRid, + ticketId: ticket.ticket.id + ) + } label: { + HomeAssignedTicketRow(ticket: ticket) + } + } + + if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "No open tickets assigned to you.", + systemImage: "person.crop.circle.badge.checkmark" + ) + } + } + .navigationTitle("Assigned Tickets") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await viewModel.loadDashboard() + } + .overlay { + if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + SRHTLoadingStateView(message: "Loading assigned tickets…") + } + } + } +} + +private struct HomeSectionMessageRow: View { + let text: String + let systemImage: String + var emphasized = false + var accessibilityHint: String? = nil + + var body: some View { + Label(text, systemImage: systemImage) + .font(.subheadline) + .foregroundStyle(emphasized ? .secondary : .tertiary) + .accessibilityHint(accessibilityHint ?? "") + } +} diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift new file mode 100644 index 0000000..31dec1b --- /dev/null +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -0,0 +1,627 @@ +import Foundation + +private struct HomeJobsResponse: Decodable, Sendable { + let jobs: HomeJobsPage +} + +private struct HomeJobsPage: Decodable, Sendable { + let results: [HomeJobPayload] +} + +private struct HomeTrackersResponse: Decodable, Sendable { + let trackers: HomeTrackersPage +} + +private struct HomeTrackersPage: Decodable, Sendable { + let results: [TrackerSummary] + let cursor: String? +} + +private struct HomeTrackerTicketsResponse: Decodable, Sendable { + let user: HomeTrackerTicketsUser +} + +private struct HomeTrackerTicketsUser: Decodable, Sendable { + let tracker: HomeTrackerTicketsTracker +} + +private struct HomeTrackerTicketsTracker: Decodable, Sendable { + let tickets: HomeTrackerTicketsPage +} + +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 + let status: TicketStatus + let resolution: TicketResolution? + let created: Date + let submitter: Entity + let labels: [TicketLabel] + let assignees: [Entity] + + enum CodingKeys: String, CodingKey { + case id + case title = "subject" + case status + case resolution + case created + case submitter + case labels + case assignees + } + + var ticketSummary: TicketSummary { + TicketSummary( + id: id, + title: title, + status: status, + resolution: resolution, + created: created, + submitter: submitter, + labels: labels, + assignees: assignees + ) + } +} + +struct HomeAssignedTicket: Identifiable, Hashable, Sendable { + let trackerId: Int + let trackerRid: String + let trackerName: String + let ownerCanonicalName: String + let ticket: TicketSummary + + var id: String { + "\(trackerRid)#\(ticket.id)" + } + + var ownerUsername: String { + if ownerCanonicalName.hasPrefix("~") { + return String(ownerCanonicalName.dropFirst()) + } + return ownerCanonicalName + } +} + +struct HomeBuildItem: Identifiable, Hashable, Sendable { + let job: JobSummary + let repositoryName: String? + let repositoryOwner: String? + + var id: Int { job.id } + + var repositoryDisplayName: String? { + guard let repositoryName else { return nil } + if let repositoryOwner { + return "\(repositoryOwner)/\(repositoryName)" + } + return repositoryName + } +} + +@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 + private(set) var failedBuildsError: String? + private(set) var assignedTicketsError: String? + private(set) var recentBuildsError: String? + + 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 { + jobs { + results { + id + created + updated + status + note + tags + visibility + image + tasks { name status } + manifest + } + } + } + """ + + private static let trackersQuery = """ + query trackers($cursor: Cursor) { + trackers(cursor: $cursor) { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + cursor + } + } + """ + + private static let trackerTicketsQuery = """ + query tickets($owner: String!, $tracker: String!) { + user(username: $owner) { + tracker(name: $tracker) { + tickets { + results { + id + subject + status + resolution + created + submitter { canonicalName } + labels { id name backgroundColor foregroundColor } + assignees { canonicalName } + } + } + } + } + } + """ + + 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 + failedBuildsError = nil + 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 + + switch recentJobsResult { + case .success(let recentJobs): + let buildItems = Self.buildItems(from: recentJobs) + self.recentBuilds = buildItems + self.failedBuilds = Self.failedBuilds(from: buildItems) + self.failedBuildsError = nil + self.recentBuildsError = nil + case .failure(let error): + self.recentBuilds = [] + self.failedBuilds = [] + self.failedBuildsError = error.localizedDescription + self.recentBuildsError = error.localizedDescription + } + isLoadingFailedBuilds = false + isLoadingRecentBuilds = false + + let assignedTicketsResult = await assignedTicketsTask + + switch assignedTicketsResult { + case .success(let assignedTickets): + self.assignedTickets = assignedTickets + self.assignedTicketsError = nil + case .failure(let error): + self.assignedTickets = [] + 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> { + do { + let response = try await client.execute( + service: .builds, + query: Self.jobsQuery, + responseType: HomeJobsResponse.self + ) + return .success(response.jobs.results) + } catch { + return .failure(error) + } + } + + 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() + let tickets = try await fetchAssignedTickets(for: trackers) + .sorted { $0.ticket.created > $1.ticket.created } + return .success(tickets) + } catch { + return .failure(error) + } + } + + private func fetchAllTrackers() async throws -> [TrackerSummary] { + var allTrackers: [TrackerSummary] = [] + var cursor: String? + + while true { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .todo, + query: Self.trackersQuery, + variables: variables.isEmpty ? nil : variables, + responseType: HomeTrackersResponse.self + ) + + allTrackers.append(contentsOf: response.trackers.results) + guard let nextCursor = response.trackers.cursor else { + break + } + cursor = nextCursor + } + + return allTrackers + } + + private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] { + guard !trackers.isEmpty else { return [] } + + var assignedTickets: [HomeAssignedTicket] = [] + var startIndex = trackers.startIndex + + while startIndex < trackers.endIndex { + let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex + let batch = Array(trackers[startIndex..<endIndex]) + + let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in + for tracker in batch { + group.addTask { + try await self.fetchAssignedTickets(for: tracker) + } + } + + var ticketsForBatch: [HomeAssignedTicket] = [] + for try await tickets in group { + ticketsForBatch.append(contentsOf: tickets) + } + return ticketsForBatch + } + + assignedTickets.append(contentsOf: batchTickets) + startIndex = endIndex + } + + return assignedTickets + } + + private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { + let response = try await client.execute( + service: .todo, + query: Self.trackerTicketsQuery, + variables: [ + "owner": tracker.owner.canonicalName.hasPrefix("~") + ? String(tracker.owner.canonicalName.dropFirst()) + : tracker.owner.canonicalName, + "tracker": tracker.name + ], + responseType: HomeTrackerTicketsResponse.self + ) + + return response.user.tracker.tickets.results.compactMap { payload in + guard payload.status.isOpen else { + return nil + } + guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else { + return nil + } + + return HomeAssignedTicket( + trackerId: tracker.id, + trackerRid: tracker.rid, + trackerName: tracker.name, + ownerCanonicalName: tracker.owner.canonicalName, + ticket: payload.ticketSummary + ) + } + } + + nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { + jobs.map { job in + let repository = primaryRepositoryReference(in: job.manifest) + return HomeBuildItem( + job: job.jobSummary, + repositoryName: repository?.name, + repositoryOwner: repository?.ownerCanonicalName + ) + } + } + + nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] { + builds.filter { build in + switch build.job.status { + case .failed, .timeout: + true + default: + false + } + } + } + + nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { + failedBuilds(from: buildItems(from: jobs)) + } + + nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) + if assigneeCanonical == currentCanonical { + return true + } + + let assigneeUsername = normalizedUsername(entity.canonicalName) + let currentUsername = normalizedUsername(currentUser.username) + return assigneeUsername == currentUsername + } + + nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? { + guard let manifest else { return nil } + let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest) + guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange), + let ownerRange = Range(match.range(at: 1), in: manifest), + let nameRange = Range(match.range(at: 2), in: manifest) else { + return nil + } + + let owner = String(manifest[ownerRange]) + var name = String(manifest[nameRange]) + if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { + name.removeSubrange(suffixRange) + } + name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !name.isEmpty else { return nil } + return (owner, name) + } + + private nonisolated static func normalizedCanonicalName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private nonisolated static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return String(trimmed.dropFirst()) + } + return trimmed + } +} +struct HomeJobPayload: Decodable, Sendable { + let id: Int + let created: Date + let updated: Date + let status: JobStatus + let note: String? + let tags: [String] + let visibility: Visibility? + let image: String? + let tasks: [JobTaskSummary] + let manifest: String? + + nonisolated var jobSummary: JobSummary { + JobSummary( + id: id, + created: created, + updated: updated, + status: status, + note: note, + tags: tags, + visibility: visibility, + image: image, + tasks: tasks + ) + } +} diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift new file mode 100644 index 0000000..304e62d --- /dev/null +++ b/Hutch/Views/Inbox/InboxView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct InboxView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: InboxViewModel? + + var body: some View { + Group { + if let viewModel { + listContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading inbox…") + } + } + .navigationTitle("Inbox") + .task { + if viewModel == nil { + let vm = InboxViewModel(client: appState.client) + viewModel = vm + await vm.loadThreads() + } + } + } + + @ViewBuilder + private func listContent(_ viewModel: InboxViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.threads) { thread in + NavigationLink(value: thread) { + InboxThreadRow(thread: thread) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + readStateAction(for: thread, in: viewModel) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + readStateAction(for: thread, in: viewModel) + } + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.threads.isEmpty { + SRHTLoadingStateView(message: "Loading inbox…") + } else if let error = viewModel.error, viewModel.threads.isEmpty { + SRHTErrorStateView( + title: "Failed to load inbox", + message: error, + retryAction: { await viewModel.loadThreads() } + ) + } else if viewModel.threads.isEmpty, viewModel.error == nil { + ContentUnavailableView( + "Inbox Zero", + systemImage: "tray", + description: Text("Unread threads will appear here.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) { + await viewModel.loadThreads() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadThreads() + } + .navigationDestination(for: InboxThreadSummary.self) { thread in + ThreadDetailView(thread: thread) { + viewModel.markThreadRead(thread) + } + } + } + + @ViewBuilder + private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.markThreadRead(thread) + } + } label: { + Label("Mark as Read", systemImage: "envelope.open") + } + .tint(.blue) + } +} + +struct InboxThreadRow: View { + let thread: InboxThreadSummary + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(thread.isUnread ? .blue : .clear) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + Text(thread.displaySubject) + .font(.subheadline.weight(thread.isUnread ? .semibold : .medium)) + .lineLimit(2) + + HStack(spacing: 8) { + if thread.containsPatch { + Image(systemName: "arrow.triangle.branch") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(thread.metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + Text(thread.lastActivityAt.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary.opacity(0.7)) + .lineLimit(1) + } + .padding(.vertical, 2) + } +} diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift new file mode 100644 index 0000000..9c1ef45 --- /dev/null +++ b/Hutch/Views/Inbox/InboxViewModel.swift @@ -0,0 +1,371 @@ +import Foundation +import os + +private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList") + +private struct InboxSubscriptionsResponse: Decodable, Sendable { + let subscriptions: InboxSubscriptionPage +} + +private struct InboxSubscriptionPage: Decodable, Sendable { + let results: [InboxActivitySubscription] + let cursor: String? +} + +private struct InboxActivitySubscription: Decodable, Sendable { + let id: Int + let created: Date + let list: InboxMailingListReference? + + enum CodingKeys: String, CodingKey { + case id + case created + case list + } +} + +private struct InboxListThreadsResponse: Decodable, Sendable { + let list: InboxMailingListThreads +} + +private struct InboxMailingListThreads: Decodable, Sendable { + let threads: InboxThreadPage +} + +private struct InboxThreadPage: Decodable, Sendable { + let results: [InboxThreadPayload] + let cursor: String? +} + +private struct InboxThreadPayload: Decodable, Sendable { + let created: Date + let updated: Date + let subject: String + let replies: Int + let sender: Entity + let root: InboxEmailPreview +} + +private struct InboxEmailPreview: Decodable, Sendable { + let id: Int + let subject: String + let date: Date? + let received: Date + let messageID: String + let body: String + let patch: InboxPatchPreview? +} + +@Observable +@MainActor +final class InboxViewModel { + private(set) var threads: [InboxThreadSummary] = [] + private(set) var isLoading = false + var error: String? + + private let client: SRHTClient + private let listThreadFetchLimit = 10 + private let listFetchConcurrencyLimit = 4 + + private static let subscriptionsQuery = """ + query inboxSubscriptions($cursor: Cursor) { + subscriptions(cursor: $cursor) { + results { + ... on MailingListSubscription { + id + created + list { + id + rid + name + owner { canonicalName } + } + } + } + cursor + } + } + """ + + private static let listThreadsQuery = """ + query inboxListThreads($rid: ID!, $cursor: Cursor) { + list(rid: $rid) { + threads(cursor: $cursor) { + results { + created + updated + subject + replies + sender { canonicalName } + root { + id + subject + date + received + messageID + body + patch { subject } + } + } + cursor + } + } + } + """ + + init(client: SRHTClient) { + self.client = client + } + + func loadThreads() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + let subscriptions = try await fetchSubscriptions() + let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) + let fetchedThreads = try await fetchThreads(for: mailingLists) + threads = fetchedThreads + .filter(\.isUnread) + .sorted { lhs, rhs in + if lhs.lastActivityAt == rhs.lastActivityAt { + return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending + } + return lhs.lastActivityAt > rhs.lastActivityAt + } + } catch { + inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)") + self.error = "Failed to load inbox" + } + } + + func markThreadRead(_ thread: InboxThreadSummary) { + let viewedAt = max(Date(), thread.lastActivityAt) + InboxReadStateStore.markViewed(viewedAt, for: thread.id) + inboxListLogger.debug( + "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)" + ) + threads.removeAll { $0.id == thread.id } + } + + func markThreadUnread(_ thread: InboxThreadSummary) { + InboxReadStateStore.markUnread(for: thread.id) + inboxListLogger.debug( + "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil" + ) + updateThread(thread, isUnread: true) + } + + func toggleThreadReadState(_ thread: InboxThreadSummary) { + if thread.isUnread { + markThreadRead(thread) + } else { + markThreadUnread(thread) + } + } + + private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { + var subscriptions: [InboxActivitySubscription] = [] + 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: InboxSubscriptionsResponse.self + ) + + subscriptions.append(contentsOf: response.subscriptions.results) + guard let nextCursor = response.subscriptions.cursor else { + break + } + cursor = nextCursor + } + + return subscriptions + } + + private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] { + guard !mailingLists.isEmpty else { return [] } + + var summaries: [InboxThreadSummary] = [] + var startIndex = mailingLists.startIndex + var failureMessages: [String] = [] + + while startIndex < mailingLists.endIndex { + let endIndex = mailingLists.index( + startIndex, + offsetBy: listFetchConcurrencyLimit, + limitedBy: mailingLists.endIndex + ) ?? mailingLists.endIndex + let batch = Array(mailingLists[startIndex..<endIndex]) + + let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in + for mailingList in batch { + group.addTask { + do { + return (try await self.fetchThreads(for: mailingList), nil) + } catch { + return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)") + } + } + } + + var batchSummaries: [InboxThreadSummary] = [] + var batchFailures: [String] = [] + for await result in group { + batchSummaries.append(contentsOf: result.0) + if let failure = result.1 { + batchFailures.append(failure) + } + } + return (batchSummaries, batchFailures) + } + + summaries.append(contentsOf: batchResult.0) + failureMessages.append(contentsOf: batchResult.1) + for failure in batchResult.1 { + inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)") + } + startIndex = endIndex + } + + if summaries.isEmpty, let firstFailure = failureMessages.first { + throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)]) + } + + return deduplicateThreads(summaries) + } + + private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] { + let response = try await client.execute( + service: .lists, + query: Self.listThreadsQuery, + variables: ["rid": mailingList.rid], + responseType: InboxListThreadsResponse.self + ) + + return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in + let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())" + let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey) + let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated) + inboxListLogger.debug( + "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)" + ) + inboxListLogger.debug( + "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)" + ) + return InboxThreadSummary( + rootEmailID: thread.root.id, + rootMessageID: thread.root.messageID, + threadRootEmailIDs: [thread.root.id], + threadRootMessageIDs: [thread.root.messageID], + listID: mailingList.id, + listRID: mailingList.rid, + listName: mailingList.name, + listOwner: mailingList.owner, + subject: thread.subject, + latestSender: thread.sender, + lastActivityAt: thread.updated, + messageCount: thread.replies + 1, + repo: Self.deriveRepositoryName(from: mailingList.name), + containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), + 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 + } + } + + 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, + 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 deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { + var seen = Set<String>() + return mailingLists.filter { mailingList in + seen.insert(mailingList.rid).inserted + } + } + + nonisolated static func deriveRepositoryName(from listName: String) -> String? { + let separators = ["-devel", "-patches", "-dev", ".patches"] + for separator in separators where listName.hasSuffix(separator) { + return String(listName.dropLast(separator.count)) + } + return nil + } +} diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift new file mode 100644 index 0000000..6fe835c --- /dev/null +++ b/Hutch/Views/Inbox/ThreadDetailView.swift @@ -0,0 +1,374 @@ +import MessageUI +import os +import SwiftUI +import UIKit + +private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply") + +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 { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading thread…") + } + } + .navigationTitle("Thread") + .navigationBarTitleDisplayMode(.inline) + .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 }, + set: { _ in viewModel?.dismissReply() } + )) { draft in + MailComposeView(draft: draft) { result in + switch result { + case .failed(let message): + inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)") + viewModel?.error = message + case .cancelled: + inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)") + case .saved: + inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)") + case .sent: + inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)") + replySuccessMessage = "Reply handed off to Mail." + Task { + await viewModel?.loadThread() + } + } + } + } + .overlay(alignment: .top) { + if let replySuccessMessage { + Text(replySuccessMessage) + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.thinMaterial, in: Capsule()) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: replySuccessMessage) + .onChange(of: replySuccessMessage) { _, message in + guard message != nil else { return } + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + if self.replySuccessMessage == message { + self.replySuccessMessage = nil + } + } + } + } + + @ViewBuilder + private func content(_ viewModel: ThreadViewModel) -> some View { + @Bindable var vm = viewModel + + List { + if let thread = viewModel.thread { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(thread.displaySubject) + .font(.headline) + Text(headerMetadata(thread)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + if let partialWarning = viewModel.partialWarning { + Section { + Text(partialWarning) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + ForEach(thread.messages) { message in + InboxMessageRow(message: message) + } + } + } + .listStyle(.plain) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + 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() + } + } + } + } + .overlay { + if viewModel.isLoading, viewModel.thread == nil { + SRHTLoadingStateView(message: "Loading thread…") + } else if let error = viewModel.error, viewModel.thread == nil { + SRHTErrorStateView( + title: "Failed to load thread", + message: error, + retryAction: { await viewModel.loadThread() } + ) + } + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadThread() + } + } + + private func headerMetadata(_ thread: InboxThreadDetail) -> String { + var parts = [thread.listDisplayName] + if let messageCount = thread.messageCount, messageCount > 1 { + parts.append("\(messageCount) messages") + } + parts.append(thread.lastActivityAt.relativeDescription) + return parts.joined(separator: " • ") + } +} + +private struct InboxMessageRow: View { + let message: InboxMessage + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(senderLine) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + Text(message.date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if message.isPatch { + Text("Patch") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + } + } + + ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in + switch block { + case .plainText(let text): + Text(text) + .font(.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + case .diff(let diff): + ScrollView(.horizontal) { + DiffView(diff: diff) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .padding(.vertical, 6) + .listRowSeparator(.visible) + } + + private var senderLine: String { + if let email = message.senderEmailAddress, + email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame { + return "\(message.senderDisplayName) <\(email)>" + } + return message.senderDisplayName + } +} + +private struct MailComposeView: UIViewControllerRepresentable { + let draft: MailComposeDraft + let onComplete: (Result) -> Void + + enum Result { + case cancelled + case saved + case sent + case failed(String) + } + + func makeCoordinator() -> Coordinator { + Coordinator(onComplete: onComplete) + } + + func makeUIViewController(context: Context) -> UIViewController { + guard MFMailComposeViewController.canSendMail() else { + let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: { + context.coordinator.onComplete(.failed("Mail is not configured on this device.")) + })) + DispatchQueue.main.async { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + return controller + } + + let controller = MFMailComposeViewController() + controller.mailComposeDelegate = context.coordinator + controller.setToRecipients(draft.recipients) + if !draft.ccRecipients.isEmpty { + controller.setCcRecipients(draft.ccRecipients) + } + if !draft.subject.isEmpty { + controller.setSubject(draft.subject) + } + if !draft.body.isEmpty { + controller.setMessageBody(draft.body, isHTML: false) + } + return controller + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + let onComplete: (Result) -> Void + + init(onComplete: @escaping (Result) -> Void) { + self.onComplete = onComplete + } + + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error? + ) { + if error != nil { + let message = error?.localizedDescription ?? "The reply could not be sent." + presentFailureAlert(on: controller, message: message) + onComplete(.failed(message)) + return + } + switch result { + case .cancelled: + controller.dismiss(animated: true) + onComplete(.cancelled) + case .saved: + controller.dismiss(animated: true) + onComplete(.saved) + case .sent: + controller.dismiss(animated: true) + onComplete(.sent) + case .failed: + let message = "Mail could not send the reply from the configured iOS Mail account." + presentFailureAlert(on: controller, message: message) + onComplete(.failed(message)) + @unknown default: + let message = "Mail returned an unknown result while sending the reply." + presentFailureAlert(on: controller, message: message) + onComplete(.failed(message)) + } + } + + private func presentFailureAlert(on controller: UIViewController, message: String) { + guard controller.presentedViewController == nil else { return } + let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + controller.present(alert, animated: true) + } + } +} + +private final class MailUnavailableViewController: UIViewController { + private let onDismiss: () -> Void + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = "Reply" + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissSelf) + ) + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "Mail is not configured on this device." + label.textAlignment = .center + label.numberOfLines = 0 + label.textColor = .secondaryLabel + + view.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + @objc + private func dismissSelf() { + dismiss(animated: true) + onDismiss() + } +} diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift new file mode 100644 index 0000000..c042fba --- /dev/null +++ b/Hutch/Views/Inbox/ThreadViewModel.swift @@ -0,0 +1,760 @@ +import Foundation +import os + +private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox") + +private struct InboxThreadDetailResponse: Decodable, Sendable { + let list: InboxThreadDetailList? +} + +private struct InboxThreadDetailList: Decodable, Sendable { + let threads: InboxThreadPayloadPage? +} + +private struct InboxThreadLookupResponse: Decodable, Sendable { + let list: InboxThreadLookupList? +} + +private struct InboxThreadLookupList: Decodable, Sendable { + let message: InboxThreadLookupMessage? +} + +private struct InboxThreadLookupMessage: Decodable, Sendable { + let thread: InboxThreadPayloadDetail? +} + +private struct InboxThreadPayloadDetail: Decodable, Sendable { + let subject: String? + let updated: Date? + let replies: Int? + let sender: Entity? + let list: InboxMailingListReference? + let root: InboxThreadMessagePayload? + let descendants: InboxThreadMessagesPage? +} + +private struct InboxThreadPayloadPage: Decodable, Sendable { + let results: [InboxThreadPayloadDetail] + let cursor: String? +} + +private struct InboxThreadMessagesPage: Decodable, Sendable { + let results: [InboxThreadMessagePayload]? + let cursor: String? +} + +private struct InboxThreadMessagePayload: Decodable, Sendable { + let id: Int? + let sender: Entity? + let received: Date? + let date: Date? + let subject: String? + let messageID: String? + let body: String? + let rawMessage: URL? + let patch: InboxPatchPreview? +} + +@Observable +@MainActor +final class ThreadViewModel { + private(set) var thread: InboxThreadDetail? + private(set) var isLoading = false + var error: String? + var partialWarning: String? + var composeDraft: MailComposeDraft? + + private let summary: InboxThreadSummary + private let client: SRHTClient + + private static let threadDetailQuery = """ + query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) { + list(rid: $rid) { + threads(cursor: $cursor) { + results { + subject + updated + replies + sender { canonicalName } + list { + id + rid + name + owner { canonicalName } + } + root { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + descendants(cursor: $descCursor) { + results { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + cursor + } + } + cursor + } + } + } + """ + + private static let threadByMessageIDQuery = """ + query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) { + list(rid: $rid) { + message(messageID: $messageID) { + thread { + subject + updated + replies + sender { canonicalName } + list { + id + rid + name + owner { canonicalName } + } + root { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + descendants(cursor: $descCursor) { + results { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + cursor + } + } + } + } + } + """ + + init(summary: InboxThreadSummary, client: SRHTClient) { + self.summary = summary + self.client = client + } + + func loadThread() async { + guard !isLoading else { return } + isLoading = true + error = nil + partialWarning = nil + defer { isLoading = false } + + inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)") + + do { + let threadPayloads = try await fetchThreadPayloads() + + guard !threadPayloads.isEmpty else { + throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) + } + + let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference( + id: summary.listID, + rid: summary.listRID, + name: summary.listName, + owner: summary.listOwner + ) + var messagesByID: [Int: InboxMessage] = [:] + + var hadPartialReplyFailure = false + + for payload in threadPayloads { + guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else { + continue + } + messagesByID[rootMessage.id] = rootMessage + + do { + let descendantMessages = try await fetchAllDescendantMessages( + initialPayload: payload, + candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID) + ) + for message in descendantMessages { + messagesByID[message.id] = message + } + } catch { + hadPartialReplyFailure = true + inboxLogger.error( + "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } + } + + let messages = messagesByID.values.sorted { $0.date < $1.date } + guard !messages.isEmpty else { + throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)]) + } + + let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0] + thread = InboxThreadDetail( + id: summary.id, + rootEmailID: summary.rootEmailID, + rootMessageID: summary.rootMessageID, + subject: latestPayload.subject ?? summary.subject, + author: latestPayload.sender ?? summary.latestSender, + lastActivityAt: latestPayload.updated ?? summary.lastActivityAt, + mailto: nil, + listID: listReference.id, + listRID: listReference.rid, + listName: listReference.name, + listOwner: listReference.owner, + messageCount: max(messages.count, summary.messageCount ?? 0), + messages: messages + ) + if hadPartialReplyFailure { + partialWarning = "Some replies could not be loaded." + } + } catch { + if thread == nil { + self.error = "Failed to load thread" + } else { + self.error = error.localizedDescription + } + inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] { + var payloads: [InboxThreadPayloadDetail] = [] + var seenRoots = Set<String>() + + for rootMessageID in summary.threadRootMessageIDs { + guard !seenRoots.contains(rootMessageID) else { continue } + seenRoots.insert(rootMessageID) + if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) { + payloads.append(payload) + } + } + + if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) { + payloads.append(fallback) + } + + return payloads + } + + private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? { + if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) { + return messageMatchedThread + } + return try await scanThreadPages(targetRootMessageID: rootMessageID) + } + + private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? { + let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID) + inboxLogger.debug( + "Inbox thread lookup IDs: subject=\(self.summary.subject, privacy: .public) rootEmailID=\(self.summary.rootEmailID, privacy: .public) rootMessageID=\(rootMessageID, privacy: .public) candidates=\(candidateMessageIDs.joined(separator: ", "), privacy: .public)" + ) + + var lastLookupError: Error? + + for messageID in candidateMessageIDs { + inboxLogger.debug( + "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)" + ) + + do { + let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( + client: client, + query: Self.threadByMessageIDQuery, + variables: [ + "rid": self.summary.listRID, + "messageID": messageID, + "descCursor": nil as String? + ] + ) + + if let thread = response.list?.message?.thread { + return thread + } + } catch let error as SRHTError { + switch error { + case .graphQLErrors(let errors): + let combinedMessage = errors.map(\.message).joined(separator: " | ") + inboxLogger.error( + "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)" + ) + if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) { + lastLookupError = error + continue + } + throw error + default: + throw error + } + } + } + + if let lastLookupError { + inboxLogger.debug( + "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)" + ) + } + return nil + } + + private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? { + var threadCursor: String? + + while true { + var variables: [String: any Sendable] = ["rid": summary.listRID] + if let threadCursor { + variables["cursor"] = threadCursor + } + + let response: InboxThreadDetailResponse + do { + response = try await Self.executeGraphQLRequest( + client: client, + query: Self.threadDetailQuery, + variables: { + var variables = variables + variables["descCursor"] = nil as String? + return variables + }() + ) + } catch { + if Self.isRecoverableNoRows(error) { + inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + throw error + } + + guard let threadPage = response.list?.threads else { + return nil + } + + let candidates = threadPage.results.map { payload in + "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")" + }.joined(separator: " | ") + inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)") + + if let matchedThread = threadPage.results.first(where: { + $0.root?.messageID == targetRootMessageID || + $0.root?.id == summary.rootEmailID || + $0.root?.subject == summary.subject + }) { + return matchedThread + } + + guard let nextCursor = threadPage.cursor else { + return nil + } + threadCursor = nextCursor + } + } + + private func fetchAllDescendantMessages( + initialPayload: InboxThreadPayloadDetail, + candidateMessageIDs: [String] + ) async throws -> [InboxMessage] { + var messagesByID: [Int: InboxMessage] = [:] + + for payload in initialPayload.descendants?.results ?? [] { + if let message = Self.message(from: payload, fallbackID: nil) { + messagesByID[message.id] = message + } + } + + var descendantCursor = initialPayload.descendants?.cursor + while let currentCursor = descendantCursor { + guard let page = try await fetchDescendantPage( + cursor: currentCursor, + candidateMessageIDs: candidateMessageIDs + ) else { + break + } + + for payload in page.results ?? [] { + if let message = Self.message(from: payload, fallbackID: nil) { + messagesByID[message.id] = message + } + } + descendantCursor = page.cursor + } + + return messagesByID.values.sorted { $0.date < $1.date } + } + + private func fetchDescendantPage( + cursor: String, + candidateMessageIDs: [String] + ) async throws -> InboxThreadMessagesPage? { + for messageID in candidateMessageIDs { + let response: InboxThreadLookupResponse + do { + response = try await Self.executeGraphQLRequest( + client: client, + query: Self.threadByMessageIDQuery, + variables: [ + "rid": summary.listRID, + "messageID": messageID, + "descCursor": cursor + ] + ) + } catch { + if Self.isRecoverableNoRows(error) { + inboxLogger.error( + "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + continue + } + throw error + } + + if let descendants = response.list?.message?.thread?.descendants { + return descendants + } + } + + return nil + } + + func prepareReply() { + guard let thread else { + error = "This thread is not ready to reply to yet." + return + } + inboxLogger.debug( + "Preparing inbox reply: subject=\(thread.subject, privacy: .public) listRID=\(thread.listRID, privacy: .public) rootMessageID=\(thread.rootMessageID, privacy: .public) recipient=\(thread.replyRecipient, privacy: .public) senderIdentity=system-mail-account" + ) + composeDraft = MailComposeDraft( + recipients: [thread.replyRecipient], + ccRecipients: [], + subject: thread.replySubject, + body: "" + ) + } + + func dismissReply() { + composeDraft = nil + } + + private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? { + guard let payload else { return nil } + guard let id = payload.id ?? fallbackID, + let author = payload.sender, + let date = payload.date ?? payload.received, + let subject = payload.subject, + let body = payload.body else { + return nil + } + + let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author) + let displayBody = sanitizedDisplayBody(from: body) + let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil) + + return InboxMessage( + id: id, + author: author, + date: date, + subject: subject, + body: body, + senderDisplayName: normalizedIdentity.displayName, + senderEmailAddress: normalizedIdentity.emailAddress, + isPatch: payload.patch != nil, + contentBlocks: contentBlocks, + rawMessageURL: payload.rawMessage + ) + } + + nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? { + guard let components = URLComponents(string: mailto), + components.scheme?.lowercased() == "mailto" else { + return nil + } + + let recipients = components.path + .split(separator: ",") + .map { String($0) } + .filter { !$0.isEmpty } + let queryItems = components.queryItems ?? [] + let ccRecipients = queryItems + .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })? + .value? + .split(separator: ",") + .map(String.init) ?? [] + let subject = queryItems + .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })? + .value ?? "" + let body = queryItems + .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })? + .value ?? "" + + return MailComposeDraft( + recipients: recipients, + ccRecipients: ccRecipients, + subject: subject, + body: body + ) + } + + private static func messageIDCandidates(from messageID: String) -> [String] { + let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMessageID.isEmpty else { return [] } + + if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") { + return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())] + } + + return [trimmedMessageID, "<\(trimmedMessageID)>"] + } + + private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) { + guard let fromLine = leadingHeaderValue(named: "From", in: body) else { + return fallbackSenderIdentity(from: fallbackAuthor) + } + + let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines) + if let start = trimmedFromLine.lastIndex(of: "<"), + let end = trimmedFromLine.lastIndex(of: ">"), + start < end { + let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces) + let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + return (name, email.isEmpty ? nil : email) + } + return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email) + } + + if trimmedFromLine.contains("@") { + return (trimmedFromLine, trimmedFromLine) + } + + return (trimmedFromLine, nil) + } + + private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) { + let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines) + if canonicalName.contains("@") { + return (canonicalName, canonicalName) + } + if canonicalName.hasPrefix("~") { + return (String(canonicalName.dropFirst()), nil) + } + return (canonicalName, nil) + } + + private static func sanitizedDisplayBody(from body: String) -> String { + let normalizedBody = normalizeLineEndings(in: body) + let lines = normalizedBody.components(separatedBy: "\n") + let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] + var headerCount = 0 + var blankLineIndex: Int? + + for (index, line) in lines.prefix(12).enumerated() { + if line.isEmpty { + blankLineIndex = index + break + } + if headerPrefixes.contains(where: { line.hasPrefix($0) }) { + headerCount += 1 + } else if headerCount > 0 { + break + } + } + + guard headerCount >= 2, let blankLineIndex else { + return stripLeadingFromLineIfPresent(in: normalizedBody) + } + + return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") + } + + nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] { + segmentMessageBody(body, isPatch: isPatch) + } + + private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] { + guard isPatch else { + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)] + } + + let normalizedBody = normalizeLineEndings(in: body) + let lines = normalizedBody.components(separatedBy: "\n") + guard let diffStartIndex = actualDiffStartIndex(in: lines) else { + let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)] + } + + var blocks: [InboxMessageContentBlock] = [] + let leadingPlainText = lines[..<diffStartIndex] + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !leadingPlainText.isEmpty { + blocks.append(.plainText(leadingPlainText)) + } + + let remainingLines = Array(lines[diffStartIndex...]) + let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator) + + let diffLines: ArraySlice<String> + let trailingPlainText: String + if let signatureIndex { + diffLines = remainingLines[..<signatureIndex] + trailingPlainText = remainingLines[signatureIndex...] + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } else { + diffLines = remainingLines[...] + trailingPlainText = "" + } + + let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !diff.isEmpty { + blocks.append(.diff(diff)) + } + + if !trailingPlainText.isEmpty { + blocks.append(.plainText(trailingPlainText)) + } + return blocks + } + + private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? { + if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) { + return explicitDiffIndex + } + + for index in lines.indices { + let line = lines[index] + guard line.hasPrefix("--- ") else { continue } + let nextIndex = lines.index(after: index) + guard nextIndex < lines.endIndex else { continue } + let nextLine = lines[nextIndex] + guard nextLine.hasPrefix("+++ ") else { continue } + + let oldPath = String(line.dropFirst(4)) + let newPath = String(nextLine.dropFirst(4)) + let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") && + (newPath.hasPrefix("b/") || newPath == "/dev/null") + + if looksLikeUnifiedDiff { + return index + } + } + + return nil + } + + private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool { + line == "-- " || line == "--" + } + + private nonisolated static func normalizeLineEndings(in text: String) -> String { + text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } + + private static func stripLeadingFromLineIfPresent(in body: String) -> String { + let lines = body.components(separatedBy: "\n") + guard let firstLine = lines.first, firstLine.hasPrefix("From:") else { + return body + } + + var remainingLines = Array(lines.dropFirst()) + if let nextLine = remainingLines.first, nextLine.isEmpty { + remainingLines.removeFirst() + } + return remainingLines.joined(separator: "\n") + } + + private static func leadingHeaderValue(named headerName: String, in body: String) -> String? { + let prefix = "\(headerName):" + let lines = body.components(separatedBy: .newlines) + for line in lines.prefix(12) { + if line.isEmpty { + break + } + if line.hasPrefix(prefix) { + return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + } + } + return nil + } + + private static func executeGraphQLRequest<T: Decodable>( + client: SRHTClient, + query: String, + variables: [String: any Sendable] + ) async throws -> T { + guard let token = KeychainHelper.loadToken(), !token.isEmpty else { + throw SRHTError.unauthorized + } + + var request = URLRequest(url: SRHTService.lists.url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let encoder = JSONEncoder() + request.httpBody = try encoder.encode( + GraphQLRequestBody( + query: query, + variables: variables.mapValues { AnyCodable($0) } + ) + ) + + let (data, _) = try await URLSession.shared.data(for: request) + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" + inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)") + #endif + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data) + if let errors = envelope.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + guard let payload = envelope.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response")) + ) + } + return payload + } + + private static func isRecoverableNoRows(_ error: Error) -> Bool { + guard case let SRHTError.graphQLErrors(errors) = error else { + return false + } + return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") } + } +} 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/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift index b1db464..4b8e512 100644 --- a/Hutch/Views/Repositories/DiffView.swift +++ b/Hutch/Views/Repositories/DiffView.swift @@ -9,14 +9,158 @@ struct DiffView: View { let diff: String var body: some View { - let lines = diff.components(separatedBy: "\n") + VStack(alignment: .leading, spacing: 12) { + ForEach(fileSections) { section in + DiffFileSectionView(section: section) + } + } + } + + private var fileSections: [DiffFileSection] { + DiffFileSection.parse(from: normalizedDiff) + } + + private var normalizedDiff: String { + diff + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } +} + +private struct DiffFileSectionView: View { + let section: DiffFileSection + @State private var isExpanded = true + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 10) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 12) + + Text(section.filename) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) - LazyVStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 8) + + Text(section.changeSummary) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Color(.tertiarySystemBackground)) + + if isExpanded { + DiffBlockView(lines: section.lines) + } + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06)) + } + } +} + +private struct DiffBlockView: View { + let lines: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in DiffLineView(line: line) } } - .font(.caption.monospaced()) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + } +} + +private struct DiffFileSection: Identifiable { + let id: String + let filename: String + let lines: [String] + let additions: Int + let deletions: Int + + var changeSummary: String { + "+\(additions) -\(deletions)" + } + + static func parse(from diff: String) -> [DiffFileSection] { + let lines = diff.components(separatedBy: "\n") + guard !lines.isEmpty else { return [] } + + let boundaries = lines.enumerated().compactMap { index, line in + line.hasPrefix("diff --git ") ? index : nil + } + + guard !boundaries.isEmpty else { + let section = makeSection(lines: lines, fallbackIndex: 0) + return section.lines.isEmpty ? [] : [section] + } + + var sections: [DiffFileSection] = [] + for (position, startIndex) in boundaries.enumerated() { + let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count + let sectionLines = Array(lines[startIndex..<endIndex]) + let section = makeSection(lines: sectionLines, fallbackIndex: position) + if !section.lines.isEmpty { + sections.append(section) + } + } + return sections + } + + private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection { + let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)" + let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count + let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count + return DiffFileSection( + id: "\(fallbackIndex)-\(filename)", + filename: filename, + lines: lines, + additions: additions, + deletions: deletions + ) + } + + private static func fileName(from lines: [String]) -> String? { + if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) { + let parts = diffHeader.split(separator: " ") + if let rhs = parts.last, rhs.hasPrefix("b/") { + return String(rhs.dropFirst(2)) + } + } + + if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) { + let path = String(plusHeader.dropFirst(4)) + if path.hasPrefix("b/") { + return String(path.dropFirst(2)) + } + return path + } + + if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) { + let path = String(minusHeader.dropFirst(4)) + if path.hasPrefix("a/") { + return String(path.dropFirst(2)) + } + return path + } + + return nil } } @@ -27,7 +171,6 @@ private struct DiffLineView: View { Text(line.isEmpty ? " " : line) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) - .padding(.vertical, 1) .background(backgroundColor) .foregroundStyle(foregroundColor) .fontWeight(isHeader ? .semibold : .regular) @@ -47,9 +190,9 @@ private struct DiffLineView: View { switch kind { case .added: .green.opacity(0.15) case .removed: .red.opacity(0.15) - case .hunk: .gray.opacity(0.12) - case .fileHeader: .gray.opacity(0.08) - case .meta: .gray.opacity(0.05) + case .hunk: .clear + case .fileHeader: .clear + case .meta: .clear case .context: .clear } } diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift index 6fcfafa..7cac8a8 100644 --- a/Hutch/Views/Repositories/RepositoryListView.swift +++ b/Hutch/Views/Repositories/RepositoryListView.swift @@ -63,7 +63,10 @@ struct RepositoryListView: View { List { ForEach(viewModel.repositories) { repo in NavigationLink(value: repo) { - RepositoryRowView(repository: repo) + RepositoryRowView( + repository: repo, + buildStatus: viewModel.latestBuildStatus(for: repo) + ) } .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } .task { diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index 6b63203..5bdc4a5 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable { final class RepositoryListViewModel { private(set) var repositories: [RepositorySummary] = [] + private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:] private(set) var isLoading = false private(set) var isLoadingMore = false private(set) var isRefreshing = false @@ -41,9 +42,11 @@ final class RepositoryListViewModel { private(set) var hasLoadedSearchIndex = false private var searchIndex: [RepositorySummary] = [] private let client: SRHTClient + private var buildStatusTask: Task<Void, Never>? private static let gitCacheKey = "git.repositories" private static let hgCacheKey = "hg.repositories" + private static let buildsCacheKey = "builds.repository-status" private static let minimumRemoteSearchLength = 3 init(client: SRHTClient) { @@ -117,6 +120,20 @@ final class RepositoryListViewModel { } """ + private static let buildsQuery = """ + query jobs($cursor: Cursor) { + jobs(cursor: $cursor) { + results { + id + created + status + manifest + } + cursor + } + } + """ + // MARK: - Public API /// Fetch the first page of repositories. Shows cached data instantly if available, @@ -168,6 +185,7 @@ final class RepositoryListViewModel { } repositories = filteredResults.sorted(by: repositorySortOrder) + scheduleBuildStatusRefresh() } catch { // Only show error if we have no cached data to fall back on if repositories.isEmpty { @@ -245,6 +263,7 @@ final class RepositoryListViewModel { } repositories.insert(repository, at: 0) insertIntoSearchIndex(repository) + scheduleBuildStatusRefresh() return repository } catch { self.error = repositoryCreationErrorMessage(for: error) @@ -306,6 +325,22 @@ final class RepositoryListViewModel { let createRepository: HGRepositoryPayload } + private struct BuildJobsResponse: Decodable, Sendable { + let jobs: BuildJobsPage + } + + private struct BuildJobsPage: Decodable, Sendable { + let results: [BuildStatusPayload] + let cursor: String? + } + + private struct BuildStatusPayload: Decodable, Sendable { + let id: Int + let created: Date + let status: JobStatus + let manifest: String? + } + private struct HGPage: Decodable, Sendable { let results: [HGRepositoryPayload] let cursor: String? @@ -380,6 +415,10 @@ final class RepositoryListViewModel { searchIndex } + func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus { + latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none + } + private func fetchPage( service: SRHTService, cursor: String?, @@ -514,9 +553,94 @@ final class RepositoryListViewModel { let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) repositories = sortedRepositories updateSearchIndex(with: sortedRepositories) + scheduleBuildStatusRefresh() } } + private func scheduleBuildStatusRefresh() { + let repositoriesSnapshot = repositories + buildStatusTask?.cancel() + buildStatusTask = Task { [weak self] in + guard let self else { return } + await self.loadLatestBuildStatuses(for: repositoriesSnapshot) + } + } + + private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async { + let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:))) + guard !targetKeys.isEmpty else { + await MainActor.run { + latestBuildStatuses = [:] + } + return + } + + var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:] + var cursor: String? + var shouldUseCache = true + + do { + while !Task.isCancelled { + let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache) + shouldUseCache = false + + for job in page.results { + let jobStatus = Self.repositoryBuildStatus(for: job.status) + guard let manifest = job.manifest else { continue } + + for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) { + let existing = resolvedStatuses[key] + if existing == nil || existing!.0 < job.created { + resolvedStatuses[key] = (job.created, jobStatus) + } + } + } + + if resolvedStatuses.count == targetKeys.count || page.cursor == nil { + break + } + cursor = page.cursor + } + + let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in + result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none + } + + await MainActor.run { + guard repositories == self.repositories else { return } + latestBuildStatuses = finalStatuses + } + } catch { + // Build status is auxiliary data for the list. Leave the default gray state on failure. + } + } + + private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + if useCache && cursor == nil { + let result = try await client.executeAndCache( + service: .builds, + query: Self.buildsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: BuildJobsResponse.self, + cacheKey: Self.buildsCacheKey + ) + return result.jobs + } + + let result = try await client.execute( + service: .builds, + query: Self.buildsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: BuildJobsResponse.self + ) + return result.jobs + } + private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { var allRepositories: [RepositorySummary] = [] var currentCursor: String? = nil @@ -580,6 +704,67 @@ final class RepositoryListViewModel { repo.description?.lowercased().contains(lowercasedQuery) ?? false } } + + nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String { + buildStatusCacheKey( + service: repository.service, + ownerCanonicalName: repository.owner.canonicalName, + repositoryName: repository.name + ) + } + + nonisolated static func buildStatusCacheKey( + service: SRHTService, + ownerCanonicalName: String, + repositoryName: String + ) -> String { + "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())" + } + + nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus { + switch jobStatus { + case .success: + .success + case .pending, .queued, .running: + .running + case .failed, .cancelled, .timeout: + .failed + } + } + + nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> { + let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return [] + } + + let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest) + return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in + guard + let serviceRange = Range(match.range(at: 1), in: manifest), + let ownerRange = Range(match.range(at: 2), in: manifest), + let nameRange = Range(match.range(at: 3), in: manifest) + else { + return + } + + let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git + let owner = String(manifest[ownerRange]).lowercased() + var name = String(manifest[nameRange]).lowercased() + + if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { + name.removeSubrange(suffixRange) + } + name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if !name.isEmpty { + result.insert(buildStatusCacheKey( + service: service, + ownerCanonicalName: owner, + repositoryName: name + )) + } + } + } } private extension Array { diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift index 9f8cd15..f8fd205 100644 --- a/Hutch/Views/Repositories/RepositoryRowView.swift +++ b/Hutch/Views/Repositories/RepositoryRowView.swift @@ -2,6 +2,7 @@ import SwiftUI struct RepositoryRowView: View { let repository: RepositorySummary + let buildStatus: RepositoryBuildStatus var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -20,6 +21,9 @@ struct RepositoryRowView: View { .foregroundStyle(.cyan) } + if buildStatus != .none { + RepositoryBuildStatusIndicator(status: buildStatus) + } VisibilityBadge(visibility: repository.visibility) } @@ -53,6 +57,47 @@ struct RepositoryRowView: View { } } +private struct RepositoryBuildStatusIndicator: View { + let status: RepositoryBuildStatus + + var body: some View { + Circle() + .fill(color) + .frame(width: 8, height: 8) + .overlay { + Circle() + .strokeBorder(.primary.opacity(0.08)) + } + .accessibilityLabel(accessibilityLabel) + } + + private var color: Color { + switch status { + case .success: + .green + case .failed: + .red + case .running: + .orange + case .none: + .clear + } + } + + private var accessibilityLabel: String { + switch status { + case .success: + "Latest build succeeded" + case .failed: + "Latest build failed" + case .running: + "Latest build is running" + case .none: + "No recent builds" + } + } +} + // MARK: - VisibilityBadge struct VisibilityBadge: View { diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift index a2cf699..7861b40 100644 --- a/Hutch/Views/Repositories/RepositorySummarySupport.swift +++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift @@ -1,5 +1,12 @@ import SwiftUI +enum RepositoryBuildStatus: Sendable { + case success + case failed + case running + case none +} + struct RepositoryCloneURLs { let readOnly: String let readWrite: String 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() } } } diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index f553bd8..5ca8618 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -132,7 +132,7 @@ struct TicketDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { // Header - ticketHeader(ticket) + ticketHeader(ticket, viewModel: viewModel) Divider() .padding(.vertical, 12) @@ -186,10 +186,16 @@ struct TicketDetailView: View { // MARK: - Header @ViewBuilder - private func ticketHeader(_ ticket: TicketDetail) -> some View { + private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(ticket.title) - .font(.title3.weight(.semibold)) + HStack(alignment: .top, spacing: 12) { + Text(ticket.title) + .font(.title3.weight(.semibold)) + + Spacer(minLength: 12) + + assignToMeButton(ticket: ticket, viewModel: viewModel) + } HStack(spacing: 8) { TicketStatusIcon(status: ticket.status) @@ -235,6 +241,43 @@ struct TicketDetailView: View { .padding() } + @ViewBuilder + private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { + if let currentUser = appState.currentUser { + let isAssignedToCurrentUser = ticket.assignees.contains { + TicketDetailViewModel.matchesAssignee($0, user: currentUser) + } + + if isAssignedToCurrentUser { + Label("Assigned to you", systemImage: "checkmark.circle.fill") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(.secondarySystemFill), in: Capsule()) + } else { + Button { + Task { + await viewModel.assignToCurrentUser(currentUser) + } + } label: { + if viewModel.isPerformingAction { + ProgressView() + .controlSize(.small) + .frame(minWidth: 88) + } else { + Text("Assign to Me") + .font(.caption.weight(.semibold)) + .frame(minWidth: 88) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(viewModel.isPerformingAction) + } + } + } + // MARK: - Comment Input @ViewBuilder diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index 90e33aa..660715b 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -433,6 +433,64 @@ final class TicketDetailViewModel { isPerformingAction = false } + func assignToCurrentUser(_ user: User) async { + guard !isPerformingAction, let currentTicket = ticket else { return } + + let currentAssignees = currentTicket.assignees + let currentEntity = Entity(canonicalName: user.canonicalName) + guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else { + return + } + + isPerformingAction = true + error = nil + + ticket = TicketDetail( + id: currentTicket.id, + created: currentTicket.created, + updated: currentTicket.updated, + title: currentTicket.title, + description: currentTicket.description, + status: currentTicket.status, + resolution: currentTicket.resolution, + authenticity: currentTicket.authenticity, + submitter: currentTicket.submitter, + assignees: currentAssignees + [currentEntity], + labels: currentTicket.labels + ) + + do { + _ = try await client.execute( + service: .todo, + query: Self.assignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "userId": user.id + ], + responseType: AssignUserResponse.self + ) + await loadTicket() + } catch { + ticket = TicketDetail( + id: currentTicket.id, + created: currentTicket.created, + updated: currentTicket.updated, + title: currentTicket.title, + description: currentTicket.description, + status: currentTicket.status, + resolution: currentTicket.resolution, + authenticity: currentTicket.authenticity, + submitter: currentTicket.submitter, + assignees: currentAssignees, + labels: currentTicket.labels + ) + self.error = error.localizedDescription + } + + isPerformingAction = false + } + func unassignUser(username: String) async { guard !isPerformingAction else { return } isPerformingAction = true @@ -558,4 +616,26 @@ final class TicketDetailViewModel { isPerformingAction = false } + static func matchesAssignee(_ entity: Entity, user: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let userCanonical = normalizedCanonicalName(user.canonicalName) + if assigneeCanonical == userCanonical { + return true + } + return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username) + } + + private static func normalizedCanonicalName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } + } |
