krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

6ba4e967d5dfb5d3c7bb97a0f2662f3180595563

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T20:17:38Z

feat: implement support for projects, lists, and pastes
 Hutch.xcodeproj/project.pbxproj                   |   8 +-
 Hutch/App/AppState.swift                          |  46 +++-
 Hutch/App/RootView.swift                          | 110 +++++++--
 Hutch/Extensions/SRHTShareUI.swift                |   1 +
 Hutch/Extensions/SRHTWebURL.swift                 |   8 +
 Hutch/Models/Inbox.swift                          |   2 +-
 Hutch/Models/Paste.swift                          |  29 +++
 Hutch/Models/Project.swift                        | 101 ++++++++
 Hutch/Networking/GraphQLRequest.swift             |   2 +
 Hutch/Networking/PasteService.swift               | 231 +++++++++++++++++
 Hutch/Networking/ProjectService.swift             | 284 +++++++++++++++++++++
 Hutch/Networking/SRHTClient.swift                 | 101 ++++++++
 Hutch/Networking/SRHTService.swift                |  11 +-
 Hutch/Views/Home/HomeView.swift                   | 109 +++++++-
 Hutch/Views/Home/HomeViewModel.swift              | 184 ++++++++++++++
 Hutch/Views/Inbox/InboxView.swift                 |  15 +-
 Hutch/Views/Inbox/InboxViewModel.swift            |  10 +-
 Hutch/Views/Inbox/ThreadDetailView.swift          |  60 ++++-
 Hutch/Views/Lists/MailingListListView.swift       | 159 ++++++++++++
 Hutch/Views/More/MoreView.swift                   |  40 +++
 Hutch/Views/Pastes/PasteDetailView.swift          | 288 ++++++++++++++++++++++
 Hutch/Views/Pastes/PasteDetailViewModel.swift     | 114 +++++++++
 Hutch/Views/Pastes/PasteListView.swift            | 280 +++++++++++++++++++++
 Hutch/Views/Pastes/PasteListViewModel.swift       | 109 ++++++++
 Hutch/Views/Projects/ProjectDetailView.swift      | 135 ++++++++++
 Hutch/Views/Projects/ProjectMailingListView.swift | 282 +++++++++++++++++++++
 Hutch/Views/Settings/SettingsView.swift           |  26 +-
 HutchTests/ProjectTests.swift                     |  72 ++++++
 28 files changed, 2751 insertions(+), 66 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 6368778..23a2d62 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -363,7 +363,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 2;
+				CURRENT_PROJECT_VERSION = 5;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -380,7 +380,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.2;
+				MARKETING_VERSION = 2.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -399,7 +399,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 2;
+				CURRENT_PROJECT_VERSION = 5;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -416,7 +416,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.2;
+				MARKETING_VERSION = 2.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 7492fa6..d5a86fd 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -9,11 +9,16 @@ final class AppState {
 
     enum Tab: Hashable {
         case home
-        case inbox
         case repositories
-        case builds
         case tickets
-        case settings
+        case builds
+        case more
+    }
+
+    enum TabNavigationTarget: Hashable {
+        case repository(RepositorySummary)
+        case tracker(TrackerSummary)
+        case mailingList(InboxMailingListReference)
     }
 
     enum AuthPhase {
@@ -48,6 +53,7 @@ final class AppState {
 
     /// Set by the deep link handler; consumed by RootView to drive navigation.
     var pendingDeepLink: DeepLink?
+    var pendingTabNavigation: TabNavigationTarget?
 
     // MARK: - Init
 
@@ -126,9 +132,9 @@ final class AppState {
     // MARK: - Deep link resolution
 
     /// Resolve a repository by owner and name for deep linking.
-    func resolveRepository(owner: String, name: String) async throws -> RepositorySummary {
+    func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
         let result = try await client.execute(
-            service: .git,
+            service: service,
             query: Self.repoLookupQuery,
             variables: ["owner": owner, "name": name],
             responseType: RepoLookupResponse.self
@@ -147,6 +153,35 @@ final class AppState {
         return result.user.tracker
     }
 
+    func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
+        try await resolveRepository(
+            owner: source.ownerUsername,
+            name: source.name,
+            service: source.repoType.service
+        )
+    }
+
+    func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
+        try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
+    }
+
+    func openProjectSource(_ source: Project.SourceRepo) async throws {
+        let repository = try await resolveProjectSource(source)
+        pendingTabNavigation = .repository(repository)
+        selectedTab = .repositories
+    }
+
+    func openProjectTracker(_ tracker: Project.Tracker) async throws {
+        let resolvedTracker = try await resolveProjectTracker(tracker)
+        pendingTabNavigation = .tracker(resolvedTracker)
+        selectedTab = .tickets
+    }
+
+    func openMailingList(_ mailingList: InboxMailingListReference) {
+        pendingTabNavigation = .mailingList(mailingList)
+        selectedTab = .more
+    }
+
     // MARK: - Private
 
     private static let meQuery = """
@@ -221,6 +256,7 @@ final class AppState {
         client.responseCache.clear()
         currentUser = nil
         pendingDeepLink = nil
+        pendingTabNavigation = nil
         selectedTab = .home
     }
 
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 042494f..0885835 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -5,7 +5,7 @@ import SwiftUI
 struct RootView: View {
     @Environment(AppState.self) private var appState
     @State private var homePath = NavigationPath()
-    @State private var inboxPath = NavigationPath()
+    @State private var morePath = NavigationPath()
     @State private var repoPath = NavigationPath()
     @State private var buildsPath = NavigationPath()
     @State private var ticketsPath = NavigationPath()
@@ -34,6 +34,9 @@ struct RootView: View {
         .onChange(of: appState.authPhase) { _, newPhase in
             handleAuthPhaseChange(newPhase)
         }
+        .onChange(of: appState.pendingTabNavigation) { _, newValue in
+            consumePendingTabNavigationIfPossible(newValue)
+        }
     }
 
     // MARK: - Tab View
@@ -50,14 +53,6 @@ struct RootView: View {
                 Label("Home", systemImage: "house")
             }
 
-            NavigationStack(path: $inboxPath) {
-                InboxView()
-            }
-            .tag(AppState.Tab.inbox)
-            .tabItem {
-                Label("Inbox", systemImage: "tray")
-            }
-
             NavigationStack(path: $repoPath) {
                 RepositoryListView()
             }
@@ -78,12 +73,6 @@ struct RootView: View {
                 Label("Tickets", systemImage: "ticket")
             }
 
-            SettingsView()
-                .tag(AppState.Tab.settings)
-                .tabItem {
-                    Label("Settings", systemImage: "gear")
-                }
-
             NavigationStack(path: $buildsPath) {
                 BuildListView()
                     // Int destination used by deep links (hutch://builds/<id>).
@@ -96,6 +85,14 @@ struct RootView: View {
             .tabItem {
                 Label("Builds", systemImage: "hammer")
             }
+
+            NavigationStack(path: $morePath) {
+                MoreNavigationRoot()
+            }
+            .tag(AppState.Tab.more)
+            .tabItem {
+                Label("More", systemImage: "ellipsis.circle")
+            }
         }
         .overlay {
             if isResolvingDeepLink {
@@ -118,7 +115,7 @@ struct RootView: View {
             break
         case .unauthenticated:
             homePath = NavigationPath()
-            inboxPath = NavigationPath()
+            morePath = NavigationPath()
             repoPath = NavigationPath()
             buildsPath = NavigationPath()
             ticketsPath = NavigationPath()
@@ -135,6 +132,12 @@ struct RootView: View {
         appState.pendingDeepLink = nil
     }
 
+    private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
+        guard appState.isAuthenticated, let target else { return }
+        handleTabNavigation(target)
+        appState.pendingTabNavigation = nil
+    }
+
     private func handleDeepLink(_ link: DeepLink) {
         guard appState.isAuthenticated else { return }
 
@@ -157,6 +160,36 @@ struct RootView: View {
         }
     }
 
+    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
+        switch target {
+        case .repository(let repository):
+            repoPath = NavigationPath()
+            appState.selectedTab = .repositories
+            Task { @MainActor in
+                try? await Task.sleep(for: .milliseconds(100))
+                repoPath.append(repository)
+            }
+
+        case .tracker(let tracker):
+            ticketsPath = NavigationPath()
+            appState.selectedTab = .tickets
+            Task { @MainActor in
+                try? await Task.sleep(for: .milliseconds(100))
+                ticketsPath.append(tracker)
+            }
+
+        case .mailingList(let mailingList):
+            morePath = NavigationPath()
+            appState.selectedTab = .more
+            Task { @MainActor in
+                try? await Task.sleep(for: .milliseconds(100))
+                morePath.append(MoreRoute.lists)
+                try? await Task.sleep(for: .milliseconds(100))
+                morePath.append(MoreRoute.mailingList(mailingList))
+            }
+        }
+    }
+
     private func resolveRepositoryLink(owner: String, repo: String) {
         isResolvingDeepLink = true
         Task {
@@ -198,6 +231,51 @@ struct RootView: View {
     }
 }
 
+enum MoreDestination: Hashable {
+    case lists
+    case pastes
+    case settings
+}
+
+enum MoreRoute: Hashable {
+    case lists
+    case pastes
+    case settings
+    case mailingList(InboxMailingListReference)
+    case thread(InboxThreadSummary)
+}
+
+private struct MoreNavigationRoot: View {
+    var body: some View {
+        MoreView()
+            .navigationDestination(for: MoreRoute.self) { route in
+                switch route {
+                case .lists:
+                    MailingListListView()
+                case .pastes:
+                    PasteListView()
+                case .settings:
+                    SettingsView()
+                case .mailingList(let mailingList):
+                    MailingListDetailView(mailingList: mailingList)
+                case .thread(let thread):
+                    ThreadDetailView(
+                        thread: thread,
+                        onViewed: {
+                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+                        },
+                        onMarkRead: {
+                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+                        },
+                        onMarkUnread: {
+                            InboxReadStateStore.markUnread(for: thread.id)
+                        }
+                    )
+                }
+            }
+    }
+}
+
 // MARK: - Ticket Deep Link Navigation Target
 
 /// Hashable wrapper to push a ticket detail view from a deep link.
diff --git a/Hutch/Extensions/SRHTShareUI.swift b/Hutch/Extensions/SRHTShareUI.swift
index 9d949c9..01f3d91 100644
--- a/Hutch/Extensions/SRHTShareUI.swift
+++ b/Hutch/Extensions/SRHTShareUI.swift
@@ -9,6 +9,7 @@ enum SRHTShareTarget: String {
     case tracker = "tracker"
     case ticket = "ticket"
     case profile = "profile"
+    case paste = "paste"
 
     var fallbackMessage: String {
         "This \(rawValue) does not have a valid web URL to share."
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index f63352b..35d250d 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -76,6 +76,14 @@ enum SRHTWebURL {
         )
     }
 
+    static func paste(ownerCanonicalName: String, pasteId: String) -> URL? {
+        userScopedURL(
+            host: "paste.sr.ht",
+            ownerCanonicalName: ownerCanonicalName,
+            pathComponents: [pasteId]
+        )
+    }
+
     private static func userScopedURL(
         host: String,
         ownerCanonicalName: String,
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
index 54e2685..c89cb12 100644
--- a/Hutch/Models/Inbox.swift
+++ b/Hutch/Models/Inbox.swift
@@ -150,7 +150,7 @@ struct MailComposeDraft: Sendable {
 
 extension MailComposeDraft: Identifiable {}
 
-struct InboxMailingListReference: Decodable, Sendable, Hashable {
+struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable {
     let id: Int
     let rid: String
     let name: String
diff --git a/Hutch/Models/Paste.swift b/Hutch/Models/Paste.swift
new file mode 100644
index 0000000..b4c8f18
--- /dev/null
+++ b/Hutch/Models/Paste.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+struct PasteFile: Codable, Sendable, Hashable, Identifiable {
+    let filename: String?
+    let hash: String
+    let contents: URL?
+
+    var id: String { hash }
+}
+
+struct Paste: Codable, Sendable, Identifiable, Hashable {
+    let id: String
+    let created: Date
+    let visibility: Visibility
+    let files: [PasteFile]
+    let user: Entity
+}
+
+struct PasteUploadDraft: Identifiable, Equatable, Sendable {
+    let id: UUID
+    var filename: String
+    var contents: String
+
+    init(id: UUID = UUID(), filename: String = "", contents: String = "") {
+        self.id = id
+        self.filename = filename
+        self.contents = contents
+    }
+}
diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift
new file mode 100644
index 0000000..e6726c2
--- /dev/null
+++ b/Hutch/Models/Project.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+struct Project: Identifiable, Hashable, Sendable {
+    struct MailingList: Identifiable, Hashable, Sendable {
+        let id: String
+        let name: String
+        let description: String?
+        let visibility: Visibility
+        let owner: Entity
+
+        var ownerUsername: String {
+            owner.canonicalName.srhtUsername
+        }
+
+        var inboxReference: InboxMailingListReference {
+            InboxMailingListReference(
+                id: 0,
+                rid: id,
+                name: name,
+                owner: owner
+            )
+        }
+    }
+
+    struct SourceRepo: Identifiable, Hashable, Sendable {
+        enum RepoType: String, Decodable, Sendable {
+            case git = "GIT"
+            case hg = "HG"
+
+            var service: SRHTService {
+                switch self {
+                case .git: .git
+                case .hg: .hg
+                }
+            }
+        }
+
+        let id: String
+        let name: String
+        let description: String?
+        let visibility: Visibility
+        let owner: Entity
+        let repoType: RepoType
+
+        var ownerUsername: String {
+            owner.canonicalName.srhtUsername
+        }
+    }
+
+    struct Tracker: Identifiable, Hashable, Sendable {
+        let id: String
+        let name: String
+        let description: String?
+        let visibility: Visibility
+        let owner: Entity
+
+        var ownerUsername: String {
+            owner.canonicalName.srhtUsername
+        }
+    }
+
+    let id: String
+    let name: String
+    let description: String?
+    let website: String?
+    let visibility: Visibility
+    let tags: [String]
+    let mailingLists: [MailingList]
+    let sources: [SourceRepo]
+    let trackers: [Tracker]
+
+    var resourceSummary: String? {
+        let parts = [
+            Self.resourceCountText(count: sources.count, singular: "repo"),
+            Self.resourceCountText(count: trackers.count, singular: "tracker"),
+            Self.resourceCountText(count: mailingLists.count, singular: "list")
+        ].compactMap { $0 }
+
+        if !parts.isEmpty {
+            return parts.joined(separator: " • ")
+        }
+
+        if let website, !website.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            return "Website linked"
+        }
+
+        return nil
+    }
+
+    private static func resourceCountText(count: Int, singular: String) -> String? {
+        guard count > 0 else { return nil }
+        let label = count == 1 ? singular : "\(singular)s"
+        return "\(count) \(label)"
+    }
+}
+
+extension String {
+    var srhtUsername: String {
+        hasPrefix("~") ? String(dropFirst()) : self
+    }
+}
diff --git a/Hutch/Networking/GraphQLRequest.swift b/Hutch/Networking/GraphQLRequest.swift
index bce7b40..8924e6a 100644
--- a/Hutch/Networking/GraphQLRequest.swift
+++ b/Hutch/Networking/GraphQLRequest.swift
@@ -34,6 +34,8 @@ struct AnyCodable: Sendable, Encodable {
             try container.encode(v)
         case let v as Bool:
             try container.encode(v)
+        case let v as [String?]:
+            try container.encode(v)
         case let v as [any Sendable]:
             try container.encode(v.map { AnyCodable($0) })
         case let v as [String: any Sendable]:
diff --git a/Hutch/Networking/PasteService.swift b/Hutch/Networking/PasteService.swift
new file mode 100644
index 0000000..b9aa530
--- /dev/null
+++ b/Hutch/Networking/PasteService.swift
@@ -0,0 +1,231 @@
+import Foundation
+
+struct PasteListPage: Decodable, Sendable {
+    let results: [Paste]
+    let cursor: String?
+}
+
+final class PasteService: Sendable {
+    private let client: SRHTClient
+
+    init(client: SRHTClient) {
+        self.client = client
+    }
+
+    private static let listQuery = """
+    query pastes($cursor: Cursor) {
+        pastes(cursor: $cursor) {
+            results {
+                id
+                created
+                visibility
+                files {
+                    filename
+                    hash
+                }
+                user {
+                    canonicalName
+                }
+            }
+            cursor
+        }
+    }
+    """
+
+    private static let detailQuery = """
+    query paste($id: String!) {
+        paste(id: $id) {
+            id
+            created
+            visibility
+            files {
+                filename
+                hash
+                contents
+            }
+            user {
+                canonicalName
+            }
+        }
+    }
+    """
+
+    private static let createMutation = """
+    mutation createPaste($files: [Upload!]!, $visibility: Visibility!) {
+        create(files: $files, visibility: $visibility) {
+            id
+            created
+            visibility
+            files {
+                filename
+                hash
+                contents
+            }
+            user {
+                canonicalName
+            }
+        }
+    }
+    """
+
+    private static let updateMutation = """
+    mutation updatePaste($id: String!, $visibility: Visibility!) {
+        update(id: $id, visibility: $visibility) {
+            id
+            created
+            visibility
+            files {
+                filename
+                hash
+                contents
+            }
+            user {
+                canonicalName
+            }
+        }
+    }
+    """
+
+    private static let deleteMutation = """
+    mutation deletePaste($id: String!) {
+        delete(id: $id) {
+            id
+            created
+            visibility
+            files {
+                filename
+                hash
+            }
+            user {
+                canonicalName
+            }
+        }
+    }
+    """
+
+    private static let cacheKey = "paste.pastes"
+
+    func listPastes(cursor: String?, useCache: Bool) async throws -> PasteListPage {
+        let variables = cursor.map { ["cursor": $0 as any Sendable] }
+        let result: PasteListResponse
+        if useCache, cursor == nil {
+            result = try await client.executeAndCache(
+                service: .paste,
+                query: Self.listQuery,
+                variables: variables,
+                responseType: PasteListResponse.self,
+                cacheKey: Self.cacheKey
+            )
+        } else {
+            result = try await client.execute(
+                service: .paste,
+                query: Self.listQuery,
+                variables: variables,
+                responseType: PasteListResponse.self
+            )
+        }
+        return result.pastes ?? PasteListPage(results: [], cursor: nil)
+    }
+
+    func loadCachedPastes() -> PasteListPage? {
+        guard let data = client.responseCache.get(forKey: Self.cacheKey) else {
+            return nil
+        }
+
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .srhtFlexible
+        guard let response = try? decoder.decode(GraphQLResponse<PasteListResponse>.self, from: data) else {
+            return nil
+        }
+        return response.data?.pastes
+    }
+
+    func loadPaste(id: String) async throws -> Paste? {
+        let result = try await client.execute(
+            service: .paste,
+            query: Self.detailQuery,
+            variables: ["id": id],
+            responseType: PasteDetailResponse.self
+        )
+        return result.paste
+    }
+
+    func createPaste(files: [PasteUploadDraft], visibility: Visibility) async throws -> Paste {
+        let uploadFiles = normalizedUploadFiles(from: files)
+        let variables: [String: any Sendable] = [
+            "files": [String?](repeating: nil, count: uploadFiles.count),
+            "visibility": visibility.rawValue
+        ]
+
+        let result = try await client.executeMultipartFiles(
+            service: .paste,
+            query: Self.createMutation,
+            variables: variables,
+            files: uploadFiles.enumerated().map { index, file in
+                MultipartUploadFile(
+                    variablePath: "files.\(index)",
+                    fileData: file.data,
+                    fileName: file.fileName,
+                    mimeType: "text/plain"
+                )
+            },
+            responseType: CreatePasteResponse.self
+        )
+        return result.create
+    }
+
+    func updateVisibility(id: String, visibility: Visibility) async throws -> Paste? {
+        let result = try await client.execute(
+            service: .paste,
+            query: Self.updateMutation,
+            variables: ["id": id, "visibility": visibility.rawValue],
+            responseType: UpdatePasteResponse.self
+        )
+        return result.update
+    }
+
+    func deletePaste(id: String) async throws -> Paste? {
+        let result = try await client.execute(
+            service: .paste,
+            query: Self.deleteMutation,
+            variables: ["id": id],
+            responseType: DeletePasteResponse.self
+        )
+        return result.delete
+    }
+
+    func loadContents(from url: URL) async throws -> String {
+        try await client.fetchText(url: url)
+    }
+
+    private func normalizedUploadFiles(from files: [PasteUploadDraft]) -> [(fileName: String, data: Data)] {
+        files.compactMap { draft in
+            let text = draft.contents
+            guard let data = text.data(using: .utf8) else {
+                return nil
+            }
+
+            return (draft.filename, data)
+        }
+    }
+}
+
+private struct PasteListResponse: Decodable, Sendable {
+    let pastes: PasteListPage?
+}
+
+private struct PasteDetailResponse: Decodable, Sendable {
+    let paste: Paste?
+}
+
+private struct CreatePasteResponse: Decodable, Sendable {
+    let create: Paste
+}
+
+private struct UpdatePasteResponse: Decodable, Sendable {
+    let update: Paste?
+}
+
+private struct DeletePasteResponse: Decodable, Sendable {
+    let delete: Paste?
+}
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
new file mode 100644
index 0000000..2afb699
--- /dev/null
+++ b/Hutch/Networking/ProjectService.swift
@@ -0,0 +1,284 @@
+import Foundation
+
+private struct ProjectPageResponse: Decodable, Sendable {
+    let me: ProjectPageUser
+}
+
+private struct ProjectPageUser: Decodable, Sendable {
+    let projects: ProjectPage
+}
+
+private struct ProjectPage: Decodable, Sendable {
+    let results: [ProjectSummaryPayload]
+    let cursor: String?
+}
+
+private struct ProjectSummaryPayload: Decodable, Sendable {
+    let rid: String
+    let name: String
+    let description: String?
+    let website: String?
+    let visibility: Visibility
+    let tags: [String]
+}
+
+private struct ProjectDetailResponse: Decodable, Sendable {
+    let project: ProjectDetailPayload?
+}
+
+private struct ProjectDetailPayload: Decodable, Sendable {
+    let rid: String
+    let name: String
+    let description: String?
+    let website: String?
+    let visibility: Visibility
+    let tags: [String]
+    let mailingLists: ProjectMailingListPage
+    let sources: ProjectSourcePage
+    let trackers: ProjectTrackerPage
+}
+
+private struct ProjectMailingListPage: Decodable, Sendable {
+    let results: [ProjectMailingListPayload]
+    let cursor: String?
+}
+
+private struct ProjectMailingListPayload: Decodable, Sendable {
+    let rid: String
+    let name: String
+    let description: String?
+    let visibility: Visibility
+    let owner: Entity
+}
+
+private struct ProjectSourcePage: Decodable, Sendable {
+    let results: [ProjectSourcePayload]
+    let cursor: String?
+}
+
+private struct ProjectSourcePayload: Decodable, Sendable {
+    let rid: String
+    let name: String
+    let description: String?
+    let visibility: Visibility
+    let owner: Entity
+    let repoType: Project.SourceRepo.RepoType
+}
+
+private struct ProjectTrackerPage: Decodable, Sendable {
+    let results: [ProjectTrackerPayload]
+    let cursor: String?
+}
+
+private struct ProjectTrackerPayload: Decodable, Sendable {
+    let rid: String
+    let name: String
+    let description: String?
+    let visibility: Visibility
+    let owner: Entity
+}
+
+struct ProjectService: Sendable {
+    private let client: SRHTClient
+
+    private static let projectsQuery = """
+    query meProjects($cursor: Cursor) {
+        me {
+            projects(cursor: $cursor) {
+                results {
+                    rid
+                    name
+                    description
+                    website
+                    visibility
+                    tags
+                }
+                cursor
+            }
+        }
+    }
+    """
+
+    private static let projectDetailQuery = """
+    query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) {
+        project(rid: $rid) {
+            rid
+            name
+            description
+            website
+            visibility
+            tags
+            mailingLists(cursor: $mailingListsCursor) {
+                results {
+                    rid
+                    name
+                    description
+                    visibility
+                    owner { canonicalName }
+                }
+                cursor
+            }
+            sources(cursor: $sourcesCursor) {
+                results {
+                    rid
+                    name
+                    description
+                    visibility
+                    owner { canonicalName }
+                    repoType
+                }
+                cursor
+            }
+            trackers(cursor: $trackersCursor) {
+                results {
+                    rid
+                    name
+                    description
+                    visibility
+                    owner { canonicalName }
+                }
+                cursor
+            }
+        }
+    }
+    """
+
+    init(client: SRHTClient) {
+        self.client = client
+    }
+
+    func fetchProjects() async throws -> [Project] {
+        let summaries = try await fetchProjectSummaries()
+        guard !summaries.isEmpty else { return [] }
+
+        return try await withThrowingTaskGroup(of: Project.self) { group in
+            for summary in summaries {
+                group.addTask {
+                    try await self.fetchProjectDetail(summary: summary)
+                }
+            }
+
+            var projects: [Project] = []
+            for try await project in group {
+                projects.append(project)
+            }
+            return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+        }
+    }
+
+    private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
+        var results: [ProjectSummaryPayload] = []
+        var cursor: String?
+
+        while true {
+            var variables: [String: any Sendable] = [:]
+            if let cursor {
+                variables["cursor"] = cursor
+            }
+
+            let response = try await client.execute(
+                service: .hub,
+                query: Self.projectsQuery,
+                variables: variables.isEmpty ? nil : variables,
+                responseType: ProjectPageResponse.self
+            )
+
+            results.append(contentsOf: response.me.projects.results)
+            guard let nextCursor = response.me.projects.cursor else {
+                break
+            }
+            cursor = nextCursor
+        }
+
+        return results
+    }
+
+    private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project {
+        var mailingLists: [Project.MailingList] = []
+        var sources: [Project.SourceRepo] = []
+        var trackers: [Project.Tracker] = []
+        var mailingListsCursor: String?
+        var sourcesCursor: String?
+        var trackersCursor: String?
+
+        while true {
+            var variables: [String: any Sendable] = ["rid": summary.rid]
+            if let mailingListsCursor {
+                variables["mailingListsCursor"] = mailingListsCursor
+            }
+            if let sourcesCursor {
+                variables["sourcesCursor"] = sourcesCursor
+            }
+            if let trackersCursor {
+                variables["trackersCursor"] = trackersCursor
+            }
+
+            let response = try await client.execute(
+                service: .hub,
+                query: Self.projectDetailQuery,
+                variables: variables,
+                responseType: ProjectDetailResponse.self
+            )
+
+            guard let project = response.project else {
+                throw SRHTError.decodingError(
+                    DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
+                )
+            }
+
+            mailingLists.append(contentsOf: project.mailingLists.results.map {
+                Project.MailingList(
+                    id: $0.rid,
+                    name: $0.name,
+                    description: $0.description,
+                    visibility: $0.visibility,
+                    owner: $0.owner
+                )
+            })
+            sources.append(contentsOf: project.sources.results.map {
+                Project.SourceRepo(
+                    id: $0.rid,
+                    name: $0.name,
+                    description: $0.description,
+                    visibility: $0.visibility,
+                    owner: $0.owner,
+                    repoType: $0.repoType
+                )
+            })
+            trackers.append(contentsOf: project.trackers.results.map {
+                Project.Tracker(
+                    id: $0.rid,
+                    name: $0.name,
+                    description: $0.description,
+                    visibility: $0.visibility,
+                    owner: $0.owner
+                )
+            })
+
+            mailingListsCursor = project.mailingLists.cursor
+            sourcesCursor = project.sources.cursor
+            trackersCursor = project.trackers.cursor
+
+            if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
+                return Project(
+                    id: project.rid,
+                    name: project.name,
+                    description: project.description,
+                    website: project.website,
+                    visibility: project.visibility,
+                    tags: project.tags,
+                    mailingLists: deduplicate(mailingLists),
+                    sources: deduplicate(sources),
+                    trackers: deduplicate(trackers)
+                )
+            }
+        }
+    }
+
+    private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
+        var seen = Set<T.ID>()
+        return items.filter { item in
+            seen.insert(item.id).inserted
+        }
+    }
+}
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 6032cab..4a6b3ca 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -3,6 +3,13 @@ import os
 
 private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
 
+struct MultipartUploadFile: Sendable {
+    let variablePath: String
+    let fileData: Data
+    let fileName: String
+    let mimeType: String
+}
+
 /// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
 private struct EmptyData: Decodable {}
 
@@ -298,6 +305,100 @@ final class SRHTClient: Sendable {
         return result
     }
 
+    func executeMultipartFiles<T: Decodable>(
+        service: SRHTService,
+        query: String,
+        variables: [String: any Sendable],
+        files: [MultipartUploadFile],
+        responseType: T.Type
+    ) async throws -> T {
+        guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+            throw SRHTError.unauthorized
+        }
+
+        let boundary = "Boundary-\(UUID().uuidString)"
+
+        var request = URLRequest(url: service.url)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+
+        let operationsBody = GraphQLRequestBody(
+            query: query,
+            variables: variables.mapValues { AnyCodable($0) }
+        )
+        let operationsData = try encoder.encode(operationsBody)
+
+        let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
+            (String(index), ["variables.\(file.variablePath)"])
+        })
+        let mapData = try encoder.encode(mapDict)
+
+        var body = Data()
+
+        body.append("--\(boundary)\r\n")
+        body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
+        body.append("Content-Type: application/json\r\n\r\n")
+        body.append(operationsData)
+        body.append("\r\n")
+
+        body.append("--\(boundary)\r\n")
+        body.append("Content-Disposition: form-data; name=\"map\"\r\n")
+        body.append("Content-Type: application/json\r\n\r\n")
+        body.append(mapData)
+        body.append("\r\n")
+
+        for (index, file) in files.enumerated() {
+            body.append("--\(boundary)\r\n")
+            body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n")
+            body.append("Content-Type: \(file.mimeType)\r\n\r\n")
+            body.append(file.fileData)
+            body.append("\r\n")
+        }
+
+        body.append("--\(boundary)--\r\n")
+        request.httpBody = body
+
+        let (data, response): (Data, URLResponse)
+        do {
+            (data, response) = try await session.data(for: request)
+        } catch {
+            throw SRHTError.networkError(error)
+        }
+
+        if let http = response as? HTTPURLResponse {
+            if http.statusCode == 401 {
+                throw SRHTError.unauthorized
+            }
+            if !(200...299).contains(http.statusCode) {
+                if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
+                   let errors = gqlResponse.errors, !errors.isEmpty {
+                    throw SRHTError.graphQLErrors(errors)
+                }
+                throw SRHTError.httpError(http.statusCode)
+            }
+        }
+
+        let graphQLResponse: GraphQLResponse<T>
+        do {
+            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+        } catch {
+            throw SRHTError.decodingError(error)
+        }
+
+        if let errors = graphQLResponse.errors, !errors.isEmpty {
+            throw SRHTError.graphQLErrors(errors)
+        }
+
+        guard let result = graphQLResponse.data else {
+            throw SRHTError.decodingError(
+                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+            )
+        }
+
+        return result
+    }
+
     // MARK: - Cached Execute
 
     /// Execute a query and cache the raw response data. Returns cached data
diff --git a/Hutch/Networking/SRHTService.swift b/Hutch/Networking/SRHTService.swift
index 5db06dd..416b733 100644
--- a/Hutch/Networking/SRHTService.swift
+++ b/Hutch/Networking/SRHTService.swift
@@ -3,6 +3,7 @@ import Foundation
 /// Each Sourcehut service exposes its own GraphQL endpoint.
 enum SRHTService: String, Codable, Sendable, CaseIterable {
     case meta
+    case hub
     case git
     case hg
     case builds
@@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable {
 
     /// The GraphQL endpoint URL for this service.
     var url: URL {
-        // Force-unwrap is safe here — these are compile-time constant strings.
-        URL(string: "https://\(rawValue).sr.ht/query")!
+        switch self {
+        case .hub:
+            URL(string: "https://sr.ht/query")!
+        default:
+            // Force-unwrap is safe here — these are compile-time constant strings.
+            URL(string: "https://\(rawValue).sr.ht/query")!
+        }
     }
 
     var displayName: String {
         switch self {
         case .meta:   "Meta"
+        case .hub:    "Hub"
         case .git:    "Git"
         case .hg:     "Mercurial"
         case .builds: "Builds"
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 1e98039..55ec41e 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -4,6 +4,7 @@ struct HomeView: View {
     @Environment(AppState.self) private var appState
     @State private var viewModel: HomeViewModel?
     private let previewLimit = 4
+    private let projectPreviewLimit = 3
 
     var body: some View {
         Group {
@@ -14,6 +15,15 @@ struct HomeView: View {
             }
         }
         .navigationTitle("Home")
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                NavigationLink {
+                    InboxView()
+                } label: {
+                    HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
+                }
+            }
+        }
         .task {
             if viewModel == nil, let currentUser = appState.currentUser {
                 let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
@@ -26,16 +36,17 @@ struct HomeView: View {
     @ViewBuilder
     private func content(_ viewModel: HomeViewModel) -> some View {
         List {
+            projectsSection(viewModel)
             assignedTicketsSection(viewModel)
             recentBuildsSection(viewModel)
         }
         .listStyle(.insetGrouped)
         .overlay {
-            if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
-                viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
+            if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
+                viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
                 SRHTLoadingStateView(message: "Loading Home…")
-            } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
-                        viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+            } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
+                        viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
                         viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
                 ContentUnavailableView(
                     "All Clear",
@@ -49,6 +60,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 {
@@ -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()
             }
         }
     }
diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift
new file mode 100644
index 0000000..a94d6d2
--- /dev/null
+++ b/HutchTests/ProjectTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ProjectTests {
+    @Test
+    func resourceSummaryIncludesCounts() {
+        let project = Project(
+            id: "project-1",
+            name: "Hutch",
+            description: nil,
+            website: nil,
+            visibility: .public,
+            tags: [],
+            mailingLists: [
+                Project.MailingList(
+                    id: "list-1",
+                    name: "hutch-devel",
+                    description: nil,
+                    visibility: .public,
+                    owner: Entity(canonicalName: "~owner")
+                )
+            ],
+            sources: [
+                Project.SourceRepo(
+                    id: "repo-1",
+                    name: "hutch",
+                    description: nil,
+                    visibility: .public,
+                    owner: Entity(canonicalName: "~owner"),
+                    repoType: .git
+                ),
+                Project.SourceRepo(
+                    id: "repo-2",
+                    name: "hutch-web",
+                    description: nil,
+                    visibility: .public,
+                    owner: Entity(canonicalName: "~owner"),
+                    repoType: .git
+                )
+            ],
+            trackers: [
+                Project.Tracker(
+                    id: "tracker-1",
+                    name: "bugs",
+                    description: nil,
+                    visibility: .public,
+                    owner: Entity(canonicalName: "~owner")
+                )
+            ]
+        )
+
+        #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list")
+    }
+
+    @Test
+    func resourceSummaryFallsBackToWebsite() {
+        let project = Project(
+            id: "project-1",
+            name: "Docs",
+            description: nil,
+            website: "https://example.com",
+            visibility: .public,
+            tags: [],
+            mailingLists: [],
+            sources: [],
+            trackers: []
+        )
+
+        #expect(project.resourceSummary == "Website linked")
+    }
+}