diff options
Diffstat (limited to 'Hutch/Views')
22 files changed, 4613 insertions, 26 deletions
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 + } + } |
