diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
| commit | 6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (patch) | |
| tree | 572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Views/Projects | |
| parent | 1e3c748119c6e9eec27f02146f17ea0302ff648a (diff) | |
| download | hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.gz hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.bz2 hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.zip | |
feat: implement support for projects, lists, and pastes
Diffstat (limited to 'Hutch/Views/Projects')
| -rw-r--r-- | Hutch/Views/Projects/ProjectDetailView.swift | 135 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 282 |
2 files changed, 417 insertions, 0 deletions
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) + } +} |
