summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 15:17:38 -0500
committerChristian Cleberg <[email protected]>2026-03-19 15:17:38 -0500
commit6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (patch)
tree572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Views
parent1e3c748119c6e9eec27f02146f17ea0302ff648a (diff)
downloadhutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.gz
hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.bz2
hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.zip
feat: implement support for projects, lists, and pastes
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Home/HomeView.swift109
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift184
-rw-r--r--Hutch/Views/Inbox/InboxView.swift15
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift10
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift60
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift159
-rw-r--r--Hutch/Views/More/MoreView.swift40
-rw-r--r--Hutch/Views/Pastes/PasteDetailView.swift288
-rw-r--r--Hutch/Views/Pastes/PasteDetailViewModel.swift114
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift280
-rw-r--r--Hutch/Views/Pastes/PasteListViewModel.swift109
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift135
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift282
-rw-r--r--Hutch/Views/Settings/SettingsView.swift26
14 files changed, 1773 insertions, 38 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 1e98039..55ec41e 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -4,6 +4,7 @@ struct HomeView: View {
@Environment(AppState.self) private var appState
@State private var viewModel: HomeViewModel?
private let previewLimit = 4
+ private let projectPreviewLimit = 3
var body: some View {
Group {
@@ -14,6 +15,15 @@ struct HomeView: View {
}
}
.navigationTitle("Home")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ NavigationLink {
+ InboxView()
+ } label: {
+ HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
+ }
+ }
+ }
.task {
if viewModel == nil, let currentUser = appState.currentUser {
let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
@@ -26,16 +36,17 @@ struct HomeView: View {
@ViewBuilder
private func content(_ viewModel: HomeViewModel) -> some View {
List {
+ projectsSection(viewModel)
assignedTicketsSection(viewModel)
recentBuildsSection(viewModel)
}
.listStyle(.insetGrouped)
.overlay {
- if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
- viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
+ if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
+ viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
SRHTLoadingStateView(message: "Loading Home…")
- } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
- viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+ } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
+ viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
ContentUnavailableView(
"All Clear",
@@ -50,6 +61,25 @@ struct HomeView: View {
}
@ViewBuilder
+ private func projectsSection(_ viewModel: HomeViewModel) -> some View {
+ if !viewModel.projects.isEmpty {
+ Section {
+ ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
+ NavigationLink {
+ ProjectDetailView(project: project)
+ } label: {
+ HomeProjectRow(project: project)
+ }
+ }
+ } header: {
+ HomeSectionHeader("Projects") {
+ HomeProjectsListView(viewModel: viewModel)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
Section {
if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
@@ -123,6 +153,77 @@ struct HomeView: View {
}
+private struct HomeInboxToolbarIcon: View {
+ let hasUnreadThreads: Bool
+
+ var body: some View {
+ ZStack(alignment: .topTrailing) {
+ Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
+
+ if hasUnreadThreads {
+ Circle()
+ .fill(.blue)
+ .frame(width: 9, height: 9)
+ .offset(x: 4, y: -2)
+ }
+ }
+ .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
+ }
+}
+
+private struct HomeProjectRow: View {
+ let project: Project
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(project.name)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ if let description = project.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ if let summary = project.resourceSummary {
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct HomeProjectsListView: View {
+ let viewModel: HomeViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.projects) { project in
+ NavigationLink {
+ ProjectDetailView(project: project)
+ } label: {
+ HomeProjectRow(project: project)
+ }
+ }
+ }
+ .navigationTitle("Projects")
+ .navigationBarTitleDisplayMode(.inline)
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ .overlay {
+ if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
+ SRHTLoadingStateView(message: "Loading projects…")
+ }
+ }
+ }
+}
+
private struct HomeBuildRow: View {
let build: HomeBuildItem
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index ce27bb0..31dec1b 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -33,6 +33,36 @@ private struct HomeTrackerTicketsPage: Decodable, Sendable {
let results: [HomeTicketPayload]
}
+private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
+ let subscriptions: HomeInboxSubscriptionPage
+}
+
+private struct HomeInboxSubscriptionPage: Decodable, Sendable {
+ let results: [HomeInboxSubscription]
+ let cursor: String?
+}
+
+private struct HomeInboxSubscription: Decodable, Sendable {
+ let list: InboxMailingListReference?
+}
+
+private struct HomeInboxListThreadsResponse: Decodable, Sendable {
+ let list: HomeInboxMailingListThreads
+}
+
+private struct HomeInboxMailingListThreads: Decodable, Sendable {
+ let threads: HomeInboxThreadPage
+}
+
+private struct HomeInboxThreadPage: Decodable, Sendable {
+ let results: [HomeInboxThreadPayload]
+}
+
+private struct HomeInboxThreadPayload: Decodable, Sendable {
+ let updated: Date
+ let subject: String
+}
+
private struct HomeTicketPayload: Decodable, Sendable {
let id: Int
let title: String
@@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
@Observable
@MainActor
final class HomeViewModel {
+ private(set) var projects: [Project] = []
private(set) var failedBuilds: [HomeBuildItem] = []
private(set) var assignedTickets: [HomeAssignedTicket] = []
private(set) var recentBuilds: [HomeBuildItem] = []
+ private(set) var hasUnreadInboxThreads = false
+ private(set) var isLoadingProjects = false
private(set) var isLoadingFailedBuilds = false
private(set) var isLoadingAssignedTickets = false
private(set) var isLoadingRecentBuilds = false
@@ -118,7 +151,10 @@ final class HomeViewModel {
private let currentUser: User
private let client: SRHTClient
+ private let projectService: ProjectService
private let ticketFetchConcurrencyLimit = 6
+ private let inboxUnreadConcurrencyLimit = 4
+ private let inboxUnreadThreadPreviewLimit = 10
private static let jobsQuery = """
query jobs {
@@ -177,12 +213,45 @@ final class HomeViewModel {
}
"""
+ private static let inboxSubscriptionsQuery = """
+ query inboxSubscriptions($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription {
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let inboxListThreadsQuery = """
+ query inboxListThreads($rid: ID!) {
+ list(rid: $rid) {
+ threads {
+ results {
+ updated
+ subject
+ }
+ }
+ }
+ }
+ """
+
init(currentUser: User, client: SRHTClient) {
self.currentUser = currentUser
self.client = client
+ self.projectService = ProjectService(client: client)
}
func loadDashboard() async {
+ isLoadingProjects = true
isLoadingFailedBuilds = true
isLoadingAssignedTickets = true
isLoadingRecentBuilds = true
@@ -190,8 +259,19 @@ final class HomeViewModel {
assignedTicketsError = nil
recentBuildsError = nil
+ async let projectsTask = loadProjects()
async let jobsTask = loadRecentJobs()
async let assignedTicketsTask = loadAssignedTickets()
+ async let inboxUnreadTask = loadInboxUnreadState()
+
+ let projectsResult = await projectsTask
+ switch projectsResult {
+ case .success(let projects):
+ self.projects = projects
+ case .failure:
+ self.projects = []
+ }
+ isLoadingProjects = false
let recentJobsResult = await jobsTask
@@ -222,6 +302,16 @@ final class HomeViewModel {
self.assignedTicketsError = error.localizedDescription
}
isLoadingAssignedTickets = false
+
+ hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
+ }
+
+ private func loadProjects() async -> Result<[Project], Error> {
+ do {
+ return .success(try await projectService.fetchProjects())
+ } catch {
+ return .failure(error)
+ }
}
private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
@@ -237,6 +327,100 @@ final class HomeViewModel {
}
}
+ private func loadInboxUnreadState() async -> Bool? {
+ do {
+ return try await fetchHasUnreadInboxThreads()
+ } catch {
+ return nil
+ }
+ }
+
+ private func fetchHasUnreadInboxThreads() async throws -> Bool {
+ let mailingLists = try await fetchInboxMailingLists()
+ guard !mailingLists.isEmpty else { return false }
+
+ var startIndex = mailingLists.startIndex
+ while startIndex < mailingLists.endIndex {
+ let endIndex = mailingLists.index(
+ startIndex,
+ offsetBy: inboxUnreadConcurrencyLimit,
+ limitedBy: mailingLists.endIndex
+ ) ?? mailingLists.endIndex
+ let batch = Array(mailingLists[startIndex..<endIndex])
+
+ let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
+ for mailingList in batch {
+ group.addTask {
+ (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
+ }
+ }
+
+ for await hasUnread in group {
+ if hasUnread {
+ group.cancelAll()
+ return true
+ }
+ }
+ return false
+ }
+
+ if batchHasUnread {
+ return true
+ }
+
+ startIndex = endIndex
+ }
+
+ return false
+ }
+
+ private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
+ var subscriptions: [HomeInboxSubscription] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.inboxSubscriptionsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: HomeInboxSubscriptionsResponse.self
+ )
+
+ subscriptions.append(contentsOf: response.subscriptions.results)
+ guard let nextCursor = response.subscriptions.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ var seen = Set<String>()
+ return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
+ }
+
+ private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.inboxListThreadsQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: HomeInboxListThreadsResponse.self
+ )
+
+ return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in
+ let normalizedSubject = thread.subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .lowercased()
+ let threadID = "\(mailingList.rid)#\(normalizedSubject)"
+ return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ }
+ }
+
private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
do {
let trackers = try await fetchAllTrackers()
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index 602f8d9..304e62d 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -51,9 +51,9 @@ struct InboxView: View {
)
} else if viewModel.threads.isEmpty, viewModel.error == nil {
ContentUnavailableView(
- "No Threads",
+ "Inbox Zero",
systemImage: "tray",
- description: Text("Patch threads will appear here.")
+ description: Text("Unread threads will appear here.")
)
}
}
@@ -75,19 +75,16 @@ struct InboxView: View {
private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
- viewModel.toggleThreadReadState(thread)
+ viewModel.markThreadRead(thread)
}
} label: {
- Label(
- thread.isUnread ? "Mark as Read" : "Mark as Unread",
- systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
- )
+ Label("Mark as Read", systemImage: "envelope.open")
}
- .tint(thread.isUnread ? .blue : .gray)
+ .tint(.blue)
}
}
-private struct InboxThreadRow: View {
+struct InboxThreadRow: View {
let thread: InboxThreadSummary
var body: some View {
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 9211b40..9c1ef45 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -127,7 +127,9 @@ final class InboxViewModel {
let subscriptions = try await fetchSubscriptions()
let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
let fetchedThreads = try await fetchThreads(for: mailingLists)
- threads = fetchedThreads.sorted { lhs, rhs in
+ threads = fetchedThreads
+ .filter(\.isUnread)
+ .sorted { lhs, rhs in
if lhs.lastActivityAt == rhs.lastActivityAt {
return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
}
@@ -145,7 +147,7 @@ final class InboxViewModel {
inboxListLogger.debug(
"Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
)
- updateThread(thread, isUnread: false)
+ threads.removeAll { $0.id == thread.id }
}
func markThreadUnread(_ thread: InboxThreadSummary) {
@@ -329,6 +331,10 @@ final class InboxViewModel {
private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
let current = threads[index]
+ if !isUnread {
+ threads.remove(at: index)
+ return
+ }
threads[index] = InboxThreadSummary(
rootEmailID: current.rootEmailID,
rootMessageID: current.rootMessageID,
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
index 7677698..6fe835c 100644
--- a/Hutch/Views/Inbox/ThreadDetailView.swift
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -8,10 +8,29 @@ private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category:
struct ThreadDetailView: View {
let thread: InboxThreadSummary
let onViewed: () -> Void
+ var onMarkRead: (() -> Void)? = nil
+ var onMarkUnread: (() -> Void)? = nil
@Environment(AppState.self) private var appState
@State private var viewModel: ThreadViewModel?
@State private var replySuccessMessage: String?
+ @State private var loadedThreadID: String?
+ @State private var hasMarkedCurrentThreadViewed = false
+ @State private var suppressAutoMarkViewed = false
+ @State private var isUnread: Bool
+
+ init(
+ thread: InboxThreadSummary,
+ onViewed: @escaping () -> Void,
+ onMarkRead: (() -> Void)? = nil,
+ onMarkUnread: (() -> Void)? = nil
+ ) {
+ self.thread = thread
+ self.onViewed = onViewed
+ self.onMarkRead = onMarkRead
+ self.onMarkUnread = onMarkUnread
+ self._isUnread = State(initialValue: thread.isUnread)
+ }
var body: some View {
Group {
@@ -23,13 +42,21 @@ struct ThreadDetailView: View {
}
.navigationTitle("Thread")
.navigationBarTitleDisplayMode(.inline)
- .task {
- if viewModel == nil {
- onViewed()
- let vm = ThreadViewModel(summary: thread, client: appState.client)
- viewModel = vm
- await vm.loadThread()
- }
+ .task(id: thread.id) {
+ guard loadedThreadID != thread.id else { return }
+ let vm = ThreadViewModel(summary: thread, client: appState.client)
+ viewModel = vm
+ loadedThreadID = thread.id
+ hasMarkedCurrentThreadViewed = false
+ suppressAutoMarkViewed = false
+ isUnread = thread.isUnread
+ await vm.loadThread()
+ }
+ .onChange(of: viewModel?.thread?.id) { _, threadID in
+ guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
+ hasMarkedCurrentThreadViewed = true
+ isUnread = false
+ onViewed()
}
.sheet(item: Binding(
get: { viewModel?.composeDraft },
@@ -109,8 +136,23 @@ struct ThreadDetailView: View {
.listStyle(.plain)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
- Button("Reply") {
- viewModel.prepareReply()
+ HStack {
+ if onMarkRead != nil || onMarkUnread != nil {
+ Button(isUnread ? "Mark Read" : "Mark Unread") {
+ suppressAutoMarkViewed = !isUnread
+ if isUnread {
+ onMarkRead?()
+ isUnread = false
+ } else {
+ onMarkUnread?()
+ isUnread = true
+ }
+ }
+ }
+
+ Button("Reply") {
+ viewModel.prepareReply()
+ }
}
}
}
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
new file mode 100644
index 0000000..80d4e50
--- /dev/null
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class MailingListListViewModel {
+ private(set) var mailingLists: [InboxMailingListReference] = []
+ private(set) var isLoading = false
+ var error: String?
+
+ private let client: SRHTClient
+
+ private static let subscriptionsQuery = """
+ query mailingLists($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription {
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ func loadMailingLists() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ mailingLists = try await fetchMailingLists()
+ } catch {
+ self.error = "Failed to load mailing lists"
+ }
+ }
+
+ private func fetchMailingLists() async throws -> [InboxMailingListReference] {
+ struct Response: Decodable, Sendable {
+ let subscriptions: Page
+ }
+
+ struct Page: Decodable, Sendable {
+ let results: [Subscription]
+ let cursor: String?
+ }
+
+ struct Subscription: Decodable, Sendable {
+ let list: InboxMailingListReference?
+ }
+
+ var results: [InboxMailingListReference] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.subscriptionsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: Response.self
+ )
+
+ results.append(contentsOf: response.subscriptions.results.compactMap(\.list))
+ guard let nextCursor = response.subscriptions.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ var seen = Set<String>()
+ return results
+ .filter { seen.insert($0.rid).inserted }
+ .sorted {
+ if $0.owner.canonicalName == $1.owner.canonicalName {
+ return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ }
+ return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending
+ }
+ }
+}
+
+struct MailingListListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: MailingListListViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading mailing lists…")
+ }
+ }
+ .navigationTitle("Lists")
+ .task {
+ if viewModel == nil {
+ let vm = MailingListListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadMailingLists()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: MailingListListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.mailingLists, id: \.rid) { mailingList in
+ NavigationLink(value: MoreRoute.mailingList(mailingList)) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(mailingList.name)
+ .font(.subheadline.weight(.medium))
+ Text(mailingList.owner.canonicalName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.mailingLists.isEmpty {
+ SRHTLoadingStateView(message: "Loading mailing lists…")
+ } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Mailing Lists",
+ message: error,
+ retryAction: { await viewModel.loadMailingLists() }
+ )
+ } else if viewModel.mailingLists.isEmpty {
+ ContentUnavailableView(
+ "No Mailing Lists",
+ systemImage: "list.bullet.rectangle",
+ description: Text("Your subscribed mailing lists will appear here.")
+ )
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadMailingLists()
+ }
+ }
+}
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
new file mode 100644
index 0000000..b7c5576
--- /dev/null
+++ b/Hutch/Views/More/MoreView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct MoreView: View {
+ private let unsupportedLinks: [(title: String, url: URL)] = [
+ ("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
+ ("man.sr.ht", URL(string: "https://man.sr.ht")!),
+ ("srht.site", URL(string: "https://srht.site")!)
+ ]
+
+ var body: some View {
+ List {
+ Section {
+ NavigationLink(value: MoreRoute.lists) {
+ Label("Lists", systemImage: "list.bullet.rectangle")
+ }
+
+ NavigationLink(value: MoreRoute.pastes) {
+ Label("Pastes", systemImage: "doc.on.clipboard")
+ }
+
+ NavigationLink(value: MoreRoute.settings) {
+ Label("Settings", systemImage: "gear")
+ }
+ }
+
+ Section {
+ ForEach(unsupportedLinks, id: \.title) { item in
+ Link(destination: item.url) {
+ Label(item.title, systemImage: "safari")
+ }
+ }
+ } header: {
+ Text("External Links")
+ } footer: {
+ Text("These SourceHut services are not supported in-app and open in your browser.")
+ }
+ }
+ .navigationTitle("More")
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift
new file mode 100644
index 0000000..df3a8be
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteDetailView.swift
@@ -0,0 +1,288 @@
+import SwiftUI
+
+struct PasteDetailView: View {
+ let paste: Paste
+ var onUpdated: ((Paste) -> Void)? = nil
+ var onDeleted: ((String) -> Void)? = nil
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: PasteDetailViewModel?
+ @State private var showVisibilitySheet = false
+ @State private var showDeleteConfirmation = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading paste…")
+ }
+ }
+ .navigationTitle(displayTitle)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(
+ url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) },
+ target: .paste
+ ) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if viewModel != nil {
+ Menu {
+ Button {
+ showVisibilitySheet = true
+ } label: {
+ Label("Change Visibility", systemImage: "eye")
+ }
+
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ Label("Delete Paste", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showVisibilitySheet) {
+ if let viewModel, let currentPaste {
+ PasteVisibilitySheet(
+ currentVisibility: currentPaste.visibility,
+ isUpdating: viewModel.isUpdatingVisibility
+ ) { visibility in
+ if let updated = await viewModel.updateVisibility(visibility) {
+ onUpdated?(updated)
+ showVisibilitySheet = false
+ }
+ }
+ }
+ }
+ .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ Task {
+ if await viewModel?.deletePaste() == true {
+ onDeleted?(paste.id)
+ dismiss()
+ }
+ }
+ }
+ } message: {
+ Text("This paste will be permanently removed.")
+ }
+ .task {
+ if viewModel == nil {
+ let vm = PasteDetailViewModel(
+ pasteID: paste.id,
+ initialPaste: paste,
+ service: PasteService(client: appState.client)
+ )
+ viewModel = vm
+ await vm.loadPaste()
+ }
+ }
+ }
+
+ private var currentPaste: Paste? {
+ viewModel?.paste ?? paste
+ }
+
+ private var displayTitle: String {
+ if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
+ return filename
+ }
+ return "Paste \(paste.id)"
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PasteDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ if viewModel.isLoading, viewModel.paste == nil {
+ SRHTLoadingStateView(message: "Loading paste…")
+ } else if let error = viewModel.error, viewModel.paste == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Paste",
+ message: error,
+ retryAction: { await viewModel.loadPaste() }
+ )
+ } else if let paste = viewModel.paste {
+ List {
+ Section("Details") {
+ LabeledContent("ID", value: paste.id)
+ LabeledContent("Owner", value: paste.user.canonicalName)
+ LabeledContent("Created", value: paste.created.relativeDescription)
+ LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
+ LabeledContent("Files", value: "\(paste.files.count)")
+ }
+
+ if paste.files.count > 1 {
+ Section("Files") {
+ Picker("Selected File", selection: Binding(
+ get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
+ set: { viewModel.selectFile(hash: $0) }
+ )) {
+ ForEach(paste.files) { file in
+ Text(file.filename ?? String(file.hash.prefix(8)))
+ .tag(file.hash)
+ }
+ }
+ }
+ }
+
+ if let file = viewModel.selectedFile {
+ Section("Current File") {
+ if let filename = file.filename, !filename.isEmpty {
+ LabeledContent("Filename", value: filename)
+ }
+ LabeledContent("Hash", value: file.hash)
+ }
+
+ Section {
+ if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
+ SRHTLoadingStateView(message: "Loading paste contents…")
+ .frame(minHeight: 180)
+ } else if let contents = viewModel.selectedFileContents {
+ PasteCodeBlock(text: contents)
+ } else {
+ Text("This file’s contents are unavailable.")
+ .foregroundStyle(.secondary)
+ }
+ } header: {
+ Text("Contents")
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadPaste()
+ }
+ }
+ }
+
+ private func visibilityLabel(_ visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ return "Public"
+ case .unlisted:
+ return "Unlisted"
+ case .private:
+ return "Private"
+ }
+ }
+}
+
+private struct PasteCodeBlock: View {
+ let text: String
+
+ var body: some View {
+ ScrollView([.horizontal, .vertical], showsIndicators: true) {
+ Text(text.isEmpty ? " " : text)
+ .font(.system(.body, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 4)
+ }
+ .frame(minHeight: 220)
+ }
+}
+
+private struct PasteVisibilitySheet: View {
+ let currentVisibility: Visibility
+ let isUpdating: Bool
+ let onSave: (Visibility) async -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var visibility: Visibility
+
+ init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
+ self.currentVisibility = currentVisibility
+ self.isUpdating = isUpdating
+ self.onSave = onSave
+ _visibility = State(initialValue: currentVisibility)
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(visibilityOptions, id: \.self) { option in
+ Button {
+ visibility = option
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title(for: option))
+ .foregroundStyle(.primary)
+ Text(description(for: option))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if visibility == option {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Visibility")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task {
+ await onSave(visibility)
+ }
+ }
+ .disabled(isUpdating || visibility == currentVisibility)
+ }
+ }
+ .overlay {
+ if isUpdating {
+ ProgressView()
+ }
+ }
+ }
+ }
+
+ private var visibilityOptions: [Visibility] {
+ [.public, .unlisted, .private]
+ }
+
+ private func title(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Public"
+ case .unlisted:
+ "Unlisted"
+ case .private:
+ "Private"
+ }
+ }
+
+ private func description(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Visible to everyone and listed on your profile."
+ case .unlisted:
+ "Visible to anyone with the URL, but not listed on your profile."
+ case .private:
+ "Visible only to explicitly allowed viewers."
+ }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteDetailViewModel.swift b/Hutch/Views/Pastes/PasteDetailViewModel.swift
new file mode 100644
index 0000000..8c60edd
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteDetailViewModel.swift
@@ -0,0 +1,114 @@
+import Foundation
+
+@Observable
+@MainActor
+final class PasteDetailViewModel {
+ private(set) var paste: Paste?
+ private(set) var isLoading = false
+ private(set) var isUpdatingVisibility = false
+ private(set) var isDeleting = false
+ private(set) var loadingFileHashes: Set<String> = []
+ var error: String?
+
+ var selectedFileHash: String?
+ private(set) var fileContents: [String: String] = [:]
+
+ private let pasteID: String
+ private let service: PasteService
+
+ init(pasteID: String, initialPaste: Paste? = nil, service: PasteService) {
+ self.pasteID = pasteID
+ self.paste = initialPaste
+ self.service = service
+ self.selectedFileHash = initialPaste?.files.first?.hash
+ }
+
+ var selectedFile: PasteFile? {
+ let hash = selectedFileHash ?? paste?.files.first?.hash
+ return paste?.files.first(where: { $0.hash == hash })
+ }
+
+ var selectedFileContents: String? {
+ guard let selectedFile else { return nil }
+ return fileContents[selectedFile.hash]
+ }
+
+ func loadPaste() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let loaded = try await service.loadPaste(id: pasteID)
+ paste = loaded
+ if selectedFileHash == nil {
+ selectedFileHash = loaded?.files.first?.hash
+ }
+ await loadSelectedFileContentsIfNeeded()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func selectFile(hash: String) {
+ selectedFileHash = hash
+ Task {
+ await loadSelectedFileContentsIfNeeded()
+ }
+ }
+
+ func updateVisibility(_ visibility: Visibility) async -> Paste? {
+ guard !isUpdatingVisibility else { return nil }
+ guard let paste else { return nil }
+ guard paste.visibility != visibility else { return paste }
+
+ isUpdatingVisibility = true
+ error = nil
+ defer { isUpdatingVisibility = false }
+
+ do {
+ let updatedPaste = try await service.updateVisibility(id: paste.id, visibility: visibility)
+ if let updatedPaste {
+ self.paste = updatedPaste
+ if selectedFileHash == nil {
+ selectedFileHash = updatedPaste.files.first?.hash
+ }
+ }
+ return updatedPaste
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func deletePaste() async -> Bool {
+ guard !isDeleting else { return false }
+ isDeleting = true
+ error = nil
+ defer { isDeleting = false }
+
+ do {
+ _ = try await service.deletePaste(id: pasteID)
+ return true
+ } catch {
+ self.error = error.localizedDescription
+ return false
+ }
+ }
+
+ func loadSelectedFileContentsIfNeeded() async {
+ guard let file = selectedFile, fileContents[file.hash] == nil else { return }
+ guard let url = file.contents else { return }
+ guard !loadingFileHashes.contains(file.hash) else { return }
+
+ loadingFileHashes.insert(file.hash)
+ defer { loadingFileHashes.remove(file.hash) }
+
+ do {
+ fileContents[file.hash] = try await service.loadContents(from: url)
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
new file mode 100644
index 0000000..9d8c0d4
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -0,0 +1,280 @@
+import SwiftUI
+
+struct PasteListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: PasteListViewModel?
+ @State private var showCreatePasteSheet = false
+ @State private var createdPaste: Paste?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading pastes…")
+ }
+ }
+ .navigationTitle("Pastes")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreatePasteSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreatePasteSheet) {
+ if let viewModel {
+ CreatePasteSheet(viewModel: viewModel) { paste in
+ showCreatePasteSheet = false
+ createdPaste = paste
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdPaste != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdPaste = nil
+ }
+ }
+ )) {
+ if let createdPaste {
+ PasteDetailView(
+ paste: createdPaste,
+ onUpdated: { updated in
+ viewModel?.upsertPaste(updated)
+ },
+ onDeleted: { id in
+ viewModel?.removePaste(id: id)
+ }
+ )
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = PasteListViewModel(service: PasteService(client: appState.client))
+ viewModel = vm
+ await vm.loadPastes()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PasteListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.pastes) { paste in
+ NavigationLink(value: paste) {
+ PasteRowView(paste: paste)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: paste)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.pastes.isEmpty {
+ SRHTLoadingStateView(message: "Loading pastes…")
+ } else if let error = viewModel.error, viewModel.pastes.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Pastes",
+ message: error,
+ retryAction: { await viewModel.loadPastes() }
+ )
+ } else if viewModel.pastes.isEmpty {
+ ContentUnavailableView(
+ "No Pastes",
+ systemImage: "doc.on.clipboard",
+ description: Text("Your pastes will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
+ await viewModel.loadPastes()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadPastes()
+ }
+ .navigationDestination(for: Paste.self) { paste in
+ PasteDetailView(
+ paste: paste,
+ onUpdated: { updated in
+ viewModel.upsertPaste(updated)
+ },
+ onDeleted: { id in
+ viewModel.removePaste(id: id)
+ }
+ )
+ }
+ }
+}
+
+private struct PasteRowView: View {
+ let paste: Paste
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: "doc.text")
+ .foregroundStyle(.secondary)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(primaryTitle)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ Text(secondaryLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ VisibilityBadge(visibility: paste.visibility)
+ Text("•")
+ .foregroundStyle(.tertiary)
+ Text(paste.created.relativeDescription)
+ .foregroundStyle(.tertiary)
+ }
+ .font(.caption2)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var primaryTitle: String {
+ if let filename = paste.files.first?.filename, !filename.isEmpty {
+ return filename
+ }
+ return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
+ }
+
+ private var secondaryLine: String {
+ var parts: [String] = [paste.user.canonicalName]
+ if paste.files.count > 1 {
+ parts.append("\(paste.files.count) files")
+ } else {
+ parts.append("1 file")
+ }
+ if let firstHash = paste.files.first?.hash {
+ parts.append(String(firstHash.prefix(8)))
+ }
+ return parts.joined(separator: " • ")
+ }
+}
+
+private struct CreatePasteSheet: View {
+ let viewModel: PasteListViewModel
+ let onCreated: (Paste) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var files = [PasteUploadDraft()]
+ @State private var visibility: Visibility = .unlisted
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Files") {
+ ForEach($files) { $file in
+ VStack(alignment: .leading, spacing: 8) {
+ TextField("Filename (optional)", text: $file.filename)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ ZStack(alignment: .topLeading) {
+ if file.contents.isEmpty {
+ Text("Paste contents")
+ .foregroundStyle(.tertiary)
+ .padding(.top, 8)
+ .padding(.leading, 5)
+ .allowsHitTesting(false)
+ }
+
+ TextEditor(text: $file.contents)
+ .font(.system(.body, design: .monospaced))
+ .frame(minHeight: 180)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ .onDelete { offsets in
+ files.remove(atOffsets: offsets)
+ if files.isEmpty {
+ files = [PasteUploadDraft()]
+ }
+ }
+
+ Button {
+ files.append(PasteUploadDraft())
+ } label: {
+ Label("Add File", systemImage: "plus")
+ }
+ }
+
+ Section("Visibility") {
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ }
+
+ Section {
+ Text("Paste contents are uploaded as UTF-8 text files. Hutch can change visibility later, but the API does not support editing file contents after creation.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("New Paste")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
+ onCreated(paste)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingPaste {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Paste")
+ }
+ }
+ .disabled(!hasValidContent || viewModel.isCreatingPaste)
+ }
+ }
+ }
+ }
+
+ private var hasValidContent: Bool {
+ files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift
new file mode 100644
index 0000000..ee7d47a
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteListViewModel.swift
@@ -0,0 +1,109 @@
+import Foundation
+
+@Observable
+@MainActor
+final class PasteListViewModel {
+ private(set) var pastes: [Paste] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ private(set) var isCreatingPaste = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let service: PasteService
+
+ init(service: PasteService) {
+ self.service = service
+ }
+
+ func loadPastes() async {
+ if pastes.isEmpty, let cached = service.loadCachedPastes() {
+ pastes = cached.results
+ cursor = cached.cursor
+ hasMore = cached.cursor != nil
+ }
+
+ if pastes.isEmpty {
+ isLoading = true
+ } else {
+ isRefreshing = true
+ }
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await service.listPastes(cursor: nil, useCache: true)
+ pastes = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ if pastes.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ func loadMoreIfNeeded(currentItem: Paste) async {
+ guard let last = pastes.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+ defer { isLoadingMore = false }
+
+ do {
+ let page = try await service.listPastes(cursor: cursor, useCache: false)
+ pastes.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func createPaste(files: [PasteUploadDraft], visibility: Visibility) async -> Paste? {
+ guard !isCreatingPaste else { return nil }
+
+ let normalizedFiles = files.filter {
+ !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ guard !normalizedFiles.isEmpty else {
+ error = "Add at least one file with text content."
+ return nil
+ }
+
+ isCreatingPaste = true
+ error = nil
+ defer { isCreatingPaste = false }
+
+ do {
+ let paste = try await service.createPaste(files: normalizedFiles, visibility: visibility)
+ upsertPaste(paste)
+ return paste
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func upsertPaste(_ paste: Paste) {
+ if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
+ pastes[index] = paste
+ } else {
+ pastes.insert(paste, at: 0)
+ }
+ }
+
+ func removePaste(id: String) {
+ pastes.removeAll { $0.id == id }
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
new file mode 100644
index 0000000..89601a9
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -0,0 +1,135 @@
+import SwiftUI
+
+struct ProjectDetailView: View {
+ let project: Project
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ List {
+ headerSection
+ repositoriesSection
+ trackersSection
+ mailingListsSection
+ }
+ .navigationTitle(project.name)
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ @ViewBuilder
+ private var headerSection: some View {
+ Section {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(project.name)
+ .font(.headline)
+
+ if let description = project.description, !description.isEmpty {
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ if let website = project.website, let url = URL(string: website) {
+ Link(destination: url) {
+ Label(website, systemImage: "link")
+ .font(.subheadline)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ @ViewBuilder
+ private var repositoriesSection: some View {
+ if !project.sources.isEmpty {
+ Section("Repositories") {
+ ForEach(project.sources) { source in
+ Button {
+ Task {
+ try? await appState.openProjectSource(source)
+ dismiss()
+ }
+ } label: {
+ ProjectResourceRow(
+ title: source.name,
+ subtitle: source.owner.canonicalName,
+ detail: source.description
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var trackersSection: some View {
+ if !project.trackers.isEmpty {
+ Section("Trackers") {
+ ForEach(project.trackers) { tracker in
+ Button {
+ Task {
+ try? await appState.openProjectTracker(tracker)
+ dismiss()
+ }
+ } label: {
+ ProjectResourceRow(
+ title: tracker.name,
+ subtitle: tracker.owner.canonicalName,
+ detail: tracker.description
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var mailingListsSection: some View {
+ if !project.mailingLists.isEmpty {
+ Section("Mailing Lists") {
+ ForEach(project.mailingLists) { mailingList in
+ Button {
+ appState.openMailingList(mailingList.inboxReference)
+ dismiss()
+ } label: {
+ ProjectResourceRow(
+ title: mailingList.name,
+ subtitle: mailingList.owner.canonicalName,
+ detail: mailingList.description
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+}
+
+private struct ProjectResourceRow: View {
+ let title: String
+ let subtitle: String
+ let detail: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.subheadline.weight(.medium))
+
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ if let detail, !detail.isEmpty {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
new file mode 100644
index 0000000..df0ffe1
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -0,0 +1,282 @@
+import SwiftUI
+
+private struct ProjectMailingListThreadsResponse: Decodable, Sendable {
+ let list: ProjectMailingListThreads
+}
+
+private struct ProjectMailingListThreads: Decodable, Sendable {
+ let threads: ProjectMailingListThreadPage
+}
+
+private struct ProjectMailingListThreadPage: Decodable, Sendable {
+ let results: [ProjectMailingListThreadPayload]
+}
+
+private struct ProjectMailingListThreadPayload: Decodable, Sendable {
+ let updated: Date
+ let subject: String
+ let replies: Int
+ let sender: Entity
+ let root: ProjectMailingListRootPayload
+}
+
+private struct ProjectMailingListRootPayload: Decodable, Sendable {
+ let id: Int
+ let messageID: String
+ let patch: InboxPatchPreview?
+}
+
+@Observable
+@MainActor
+final class MailingListDetailViewModel {
+ private(set) var threads: [InboxThreadSummary] = []
+ private(set) var isLoading = false
+ var error: String?
+
+ private let mailingList: InboxMailingListReference
+ private let client: SRHTClient
+
+ private static let listThreadsQuery = """
+ query projectMailingListThreads($rid: ID!) {
+ list(rid: $rid) {
+ threads {
+ results {
+ updated
+ subject
+ replies
+ sender { canonicalName }
+ root {
+ id
+ messageID
+ patch { subject }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ init(mailingList: InboxMailingListReference, client: SRHTClient) {
+ self.mailingList = mailingList
+ self.client = client
+ }
+
+ func loadThreads() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.listThreadsQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: ProjectMailingListThreadsResponse.self
+ )
+
+ threads = deduplicateThreads(
+ response.list.threads.results.map(makeSummary(from:))
+ )
+ } catch {
+ self.error = "Failed to load mailing list"
+ }
+ }
+
+ func markThreadRead(_ thread: InboxThreadSummary) {
+ let viewedAt = max(Date(), thread.lastActivityAt)
+ InboxReadStateStore.markViewed(viewedAt, for: thread.id)
+ updateThread(thread, isUnread: false)
+ }
+
+ func markThreadUnread(_ thread: InboxThreadSummary) {
+ InboxReadStateStore.markUnread(for: thread.id)
+ updateThread(thread, isUnread: true)
+ }
+
+ private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
+ let normalizedSubject = thread.subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .lowercased()
+ let threadID = "\(mailingList.rid)#\(normalizedSubject)"
+
+ return InboxThreadSummary(
+ rootEmailID: thread.root.id,
+ rootMessageID: thread.root.messageID,
+ threadRootEmailIDs: [thread.root.id],
+ threadRootMessageIDs: [thread.root.messageID],
+ listID: 0,
+ listRID: mailingList.rid,
+ listName: mailingList.name,
+ listOwner: mailingList.owner,
+ subject: thread.subject,
+ latestSender: thread.sender,
+ lastActivityAt: thread.updated,
+ messageCount: thread.replies + 1,
+ repo: nil,
+ containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
+ isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ )
+ }
+
+ private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
+ guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ let current = threads[index]
+ threads[index] = InboxThreadSummary(
+ rootEmailID: current.rootEmailID,
+ rootMessageID: current.rootMessageID,
+ threadRootEmailIDs: current.threadRootEmailIDs,
+ threadRootMessageIDs: current.threadRootMessageIDs,
+ listID: current.listID,
+ listRID: current.listRID,
+ listName: current.listName,
+ listOwner: current.listOwner,
+ subject: current.subject,
+ latestSender: current.latestSender,
+ lastActivityAt: current.lastActivityAt,
+ messageCount: current.messageCount,
+ repo: current.repo,
+ containsPatch: current.containsPatch,
+ isUnread: isUnread
+ )
+ }
+
+ private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
+ var grouped: [String: InboxThreadSummary] = [:]
+
+ for thread in threads {
+ guard let existing = grouped[thread.threadGroupingKey] else {
+ grouped[thread.threadGroupingKey] = thread
+ continue
+ }
+
+ let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
+ let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
+ let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
+ let mergedMessageCount = max(
+ existing.messageCount ?? existing.threadRootMessageIDs.count,
+ thread.messageCount ?? thread.threadRootMessageIDs.count,
+ mergedRootMessageIDs.count
+ )
+
+ grouped[thread.threadGroupingKey] = InboxThreadSummary(
+ rootEmailID: latest.rootEmailID,
+ rootMessageID: latest.rootMessageID,
+ threadRootEmailIDs: mergedRootEmailIDs,
+ threadRootMessageIDs: mergedRootMessageIDs,
+ listID: latest.listID,
+ listRID: latest.listRID,
+ listName: latest.listName,
+ listOwner: latest.listOwner,
+ subject: latest.subject,
+ latestSender: latest.latestSender,
+ lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
+ messageCount: mergedMessageCount,
+ repo: latest.repo ?? existing.repo,
+ containsPatch: latest.containsPatch || existing.containsPatch,
+ isUnread: latest.isUnread || existing.isUnread
+ )
+ }
+
+ return grouped.values.sorted { lhs, rhs in
+ if lhs.lastActivityAt == rhs.lastActivityAt {
+ return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
+ }
+ return lhs.lastActivityAt > rhs.lastActivityAt
+ }
+ }
+}
+
+struct MailingListDetailView: View {
+ let mailingList: InboxMailingListReference
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: MailingListDetailViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading mailing list…")
+ }
+ }
+ .navigationTitle(mailingList.name)
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ if viewModel == nil {
+ let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client)
+ self.viewModel = viewModel
+ await viewModel.loadThreads()
+ }
+ }
+ .onAppear {
+ guard let viewModel else { return }
+ Task {
+ await viewModel.loadThreads()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: MailingListDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.threads) { thread in
+ NavigationLink(value: MoreRoute.thread(thread)) {
+ InboxThreadRow(thread: thread)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ if thread.isUnread {
+ viewModel.markThreadRead(thread)
+ } else {
+ viewModel.markThreadUnread(thread)
+ }
+ }
+ } label: {
+ Label(
+ thread.isUnread ? "Mark as Read" : "Mark as Unread",
+ systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
+ )
+ }
+ .tint(thread.isUnread ? .blue : .gray)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.threads.isEmpty {
+ SRHTLoadingStateView(message: "Loading mailing list…")
+ } else if let error = viewModel.error, viewModel.threads.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Mailing List",
+ message: error,
+ retryAction: { await viewModel.loadThreads() }
+ )
+ } else if viewModel.threads.isEmpty {
+ ContentUnavailableView(
+ "No Threads",
+ systemImage: "tray",
+ description: Text("This mailing list does not have any recent threads.")
+ )
+ }
+ }
+ .refreshable {
+ await viewModel.loadThreads()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ }
+}
+
+struct ProjectMailingListView: View {
+ let mailingList: Project.MailingList
+
+ var body: some View {
+ MailingListDetailView(mailingList: mailingList.inboxReference)
+ }
+}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 9b7d41f..91aa420 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -12,21 +12,19 @@ struct SettingsView: View {
@State private var pendingDestructiveAction: SettingsDestructiveAction?
var body: some View {
- NavigationStack {
- Group {
- if let viewModel {
- settingsContent(viewModel)
- } else {
- SRHTLoadingStateView(message: "Loading profile…")
- }
+ Group {
+ if let viewModel {
+ settingsContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading profile…")
}
- .navigationTitle("Settings")
- .task {
- if viewModel == nil {
- let vm = SettingsViewModel(client: appState.client)
- viewModel = vm
- await vm.loadProfile()
- }
+ }
+ .navigationTitle("Settings")
+ .task {
+ if viewModel == nil {
+ let vm = SettingsViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadProfile()
}
}
}