krz/hutch

an ios client for sourcehut

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

8b73fd9cd43753b36b3ab3ca5e4dc83a47ea3480

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-20T18:04:57Z

fix: add mark all read actions and avoid resending unchanged repo metadata
 Hutch/Views/Home/HomeViewModel.swift               | 14 ++++++
 Hutch/Views/Projects/ProjectMailingListView.swift  | 52 +++++++++++++++++++---
 .../Repositories/RepositorySettingsViewModel.swift | 19 ++++++--
 Hutch/Views/Work/WorkView.swift                    |  8 ++++
 HutchTests/RepositorySettingsViewModelTests.swift  | 33 ++++++++++++++
 5 files changed, 116 insertions(+), 10 deletions(-)

diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 4bd4f32..6016a11 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -627,6 +627,20 @@ final class HomeViewModel {
         persistNeedsAttentionSnapshot()
     }
 
+    func markAllInboxThreadsRead() {
+        guard !unreadInboxThreads.isEmpty else { return }
+
+        let viewedAt = Date()
+        for thread in unreadInboxThreads {
+            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
+        }
+
+        unreadInboxThreads = []
+        unreadInboxThreadCount = 0
+        hasUnreadInboxThreads = false
+        persistNeedsAttentionSnapshot()
+    }
+
     func markInboxThreadUnread(_ thread: InboxThreadSummary) {
         InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
         if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index d7c0abc..ca6066c 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -105,6 +105,39 @@ final class MailingListDetailViewModel {
         NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
     }
 
+    func markAllThreadsRead() {
+        let unreadThreads = threads.filter(\.isUnread)
+        guard !unreadThreads.isEmpty else { return }
+
+        let viewedAt = Date()
+        for thread in unreadThreads {
+            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
+        }
+
+        threads = threads.map { thread in
+            guard thread.isUnread else { return thread }
+            return InboxThreadSummary(
+                rootEmailID: thread.rootEmailID,
+                rootMessageID: thread.rootMessageID,
+                threadRootEmailIDs: thread.threadRootEmailIDs,
+                threadRootMessageIDs: thread.threadRootMessageIDs,
+                listID: thread.listID,
+                listRID: thread.listRID,
+                listName: thread.listName,
+                listOwner: thread.listOwner,
+                subject: thread.subject,
+                latestSender: thread.latestSender,
+                lastActivityAt: thread.lastActivityAt,
+                messageCount: thread.messageCount,
+                repo: thread.repo,
+                containsPatch: thread.containsPatch,
+                isUnread: false
+            )
+        }
+
+        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
+    }
+
     private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
         let normalizedSubject = thread.subject
             .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
@@ -241,6 +274,10 @@ struct MailingListDetailView: View {
         return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
     }
 
+    private var hasUnreadThreads: Bool {
+        viewModel?.threads.contains(where: \.isUnread) == true
+    }
+
     var body: some View {
         Group {
             if let viewModel {
@@ -252,6 +289,12 @@ struct MailingListDetailView: View {
         .navigationTitle(mailingList.name)
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                Button("Mark All Read") {
+                    viewModel?.markAllThreadsRead()
+                }
+                .disabled(hasUnreadThreads == false)
+            }
             if currentUserKey != nil {
                 ToolbarItem(placement: .topBarTrailing) {
                     Button {
@@ -299,16 +342,13 @@ struct MailingListDetailView: View {
                     ThreadDetailView(
                         thread: thread,
                         onViewed: {
-                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
-                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
+                            viewModel.markThreadRead(thread)
                         },
                         onMarkRead: {
-                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
-                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
+                            viewModel.markThreadRead(thread)
                         },
                         onMarkUnread: {
-                            InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
-                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
+                            viewModel.markThreadUnread(thread)
                         }
                     )
                 } label: {
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index 6233bec..6ad6a78 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -166,10 +166,7 @@ final class RepositorySettingsViewModel {
         defer { isSavingMetadata = false }
         error = nil
 
-        let input: [String: any Sendable] = [
-            "name": normalizedEditedName,
-            "description": normalizedEditedDescription
-        ]
+        let input = metadataInputForSave()
 
         do {
             return try await updateRepository(with: input)
@@ -295,4 +292,18 @@ final class RepositorySettingsViewModel {
             RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
         }?.name
     }
+
+    func metadataInputForSave() -> [String: any Sendable] {
+        var input: [String: any Sendable] = [:]
+
+        if normalizedEditedName != repository.name {
+            input["name"] = normalizedEditedName
+        }
+
+        if normalizedEditedDescription != (repository.description ?? "") {
+            input["description"] = normalizedEditedDescription.isEmpty ? Optional<String>.none as String? : normalizedEditedDescription
+        }
+
+        return input
+    }
 }
diff --git a/Hutch/Views/Work/WorkView.swift b/Hutch/Views/Work/WorkView.swift
index 2326236..8b932dd 100644
--- a/Hutch/Views/Work/WorkView.swift
+++ b/Hutch/Views/Work/WorkView.swift
@@ -26,6 +26,14 @@ struct WorkView: View {
         }
         .navigationTitle("Work")
         .navigationBarTitleDisplayMode(.inline)
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                Button("Mark All Read") {
+                    viewModel?.markAllInboxThreadsRead()
+                }
+                .disabled(viewModel.map { unreadCount($0) } ?? 0 == 0)
+            }
+        }
         .task {
             guard let currentUser = appState.currentUser else { return }
             await ensureViewModel(currentUser: currentUser).loadDashboard()
diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift
index e64e08f..13d0b21 100644
--- a/HutchTests/RepositorySettingsViewModelTests.swift
+++ b/HutchTests/RepositorySettingsViewModelTests.swift
@@ -115,6 +115,39 @@ struct RepositorySettingsViewModelTests {
         #expect(viewModel.isMetadataDirty == false)
     }
 
+    @Test
+    @MainActor
+    func metadataInputOmitsUnchangedNameWhenOnlyDescriptionChanges() {
+        let viewModel = RepositorySettingsViewModel(
+            repository: makeRepository(headName: "refs/heads/main"),
+            branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)],
+            client: SRHTClient(token: "test-token")
+        )
+        viewModel.editedDescription = "an ios client for sourcehut"
+
+        let input = viewModel.metadataInputForSave()
+
+        #expect(input["name"] == nil)
+        #expect(input["description"] as? String == "an ios client for sourcehut")
+    }
+
+    @Test
+    @MainActor
+    func metadataInputUsesNilToClearDescription() {
+        let viewModel = RepositorySettingsViewModel(
+            repository: makeRepository(headName: "refs/heads/main"),
+            branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)],
+            client: SRHTClient(token: "test-token")
+        )
+        viewModel.editedDescription = "   "
+
+        let input = viewModel.metadataInputForSave()
+
+        #expect(input["name"] == nil)
+        #expect(input.keys.contains("description"))
+        #expect(input["description"] as? String == nil)
+    }
+
     @MainActor
     private func makeRepository(headName: String?) -> RepositorySummary {
         RepositorySummary(