Commit 134442898a

134442898a1e807cc48d7bc2a8e4f5a535815ef4

parent: 1c32074b4f

Unsigned

cmc <hello@cleberg.net> · 2026-04-20 18:04 UTC

fix: add mark all read actions and avoid resending unchanged repo metadata

Layout: unified · split

Hutch/Views/Home/HomeViewModel.swift +14
@@ -627,6 +627,20 @@ final class HomeViewModel {
627 persistNeedsAttentionSnapshot() 627 persistNeedsAttentionSnapshot()
628 } 628 }
629 629
630 func markAllInboxThreadsRead() {
631 guard !unreadInboxThreads.isEmpty else { return }
632
633 let viewedAt = Date()
634 for thread in unreadInboxThreads {
635 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
636 }
637
638 unreadInboxThreads = []
639 unreadInboxThreadCount = 0
640 hasUnreadInboxThreads = false
641 persistNeedsAttentionSnapshot()
642 }
643
630 func markInboxThreadUnread(_ thread: InboxThreadSummary) { 644 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
631 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) 645 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
632 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false { 646 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
Hutch/Views/Projects/ProjectMailingListView.swift +46 −6
@@ -105,6 +105,39 @@ final class MailingListDetailViewModel {
105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID) 105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
106 } 106 }
107 107
108 func markAllThreadsRead() {
109 let unreadThreads = threads.filter(\.isUnread)
110 guard !unreadThreads.isEmpty else { return }
111
112 let viewedAt = Date()
113 for thread in unreadThreads {
114 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
115 }
116
117 threads = threads.map { thread in
118 guard thread.isUnread else { return thread }
119 return InboxThreadSummary(
120 rootEmailID: thread.rootEmailID,
121 rootMessageID: thread.rootMessageID,
122 threadRootEmailIDs: thread.threadRootEmailIDs,
123 threadRootMessageIDs: thread.threadRootMessageIDs,
124 listID: thread.listID,
125 listRID: thread.listRID,
126 listName: thread.listName,
127 listOwner: thread.listOwner,
128 subject: thread.subject,
129 latestSender: thread.latestSender,
130 lastActivityAt: thread.lastActivityAt,
131 messageCount: thread.messageCount,
132 repo: thread.repo,
133 containsPatch: thread.containsPatch,
134 isUnread: false
135 )
136 }
137
138 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
139 }
140
108 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { 141 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
109 let normalizedSubject = thread.subject 142 let normalizedSubject = thread.subject
110 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) 143 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
@@ -241,6 +274,10 @@ struct MailingListDetailView: View {
241 return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults) 274 return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
242 } 275 }
243 276
277 private var hasUnreadThreads: Bool {
278 viewModel?.threads.contains(where: \.isUnread) == true
279 }
280
244 var body: some View { 281 var body: some View {
245 Group { 282 Group {
246 if let viewModel { 283 if let viewModel {
@@ -252,6 +289,12 @@ struct MailingListDetailView: View {
252 .navigationTitle(mailingList.name) 289 .navigationTitle(mailingList.name)
253 .navigationBarTitleDisplayMode(.inline) 290 .navigationBarTitleDisplayMode(.inline)
254 .toolbar { 291 .toolbar {
292 ToolbarItem(placement: .topBarTrailing) {
293 Button("Mark All Read") {
294 viewModel?.markAllThreadsRead()
295 }
296 .disabled(hasUnreadThreads == false)
297 }
255 if currentUserKey != nil { 298 if currentUserKey != nil {
256 ToolbarItem(placement: .topBarTrailing) { 299 ToolbarItem(placement: .topBarTrailing) {
257 Button { 300 Button {
@@ -299,16 +342,13 @@ struct MailingListDetailView: View {
299 ThreadDetailView( 342 ThreadDetailView(
300 thread: thread, 343 thread: thread,
301 onViewed: { 344 onViewed: {
302 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) 345 viewModel.markThreadRead(thread)
303 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
304 }, 346 },
305 onMarkRead: { 347 onMarkRead: {
306 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) 348 viewModel.markThreadRead(thread)
307 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
308 }, 349 },
309 onMarkUnread: { 350 onMarkUnread: {
310 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults) 351 viewModel.markThreadUnread(thread)
311 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
312 } 352 }
313 ) 353 )
314 } label: { 354 } label: {
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +15 −4
@@ -166,10 +166,7 @@ final class RepositorySettingsViewModel {
166 defer { isSavingMetadata = false } 166 defer { isSavingMetadata = false }
167 error = nil 167 error = nil
168 168
169 let input: [String: any Sendable] = [ 169 let input = metadataInputForSave()
170 "name": normalizedEditedName,
171 "description": normalizedEditedDescription
172 ]
173 170
174 do { 171 do {
175 return try await updateRepository(with: input) 172 return try await updateRepository(with: input)
@@ -295,4 +292,18 @@ final class RepositorySettingsViewModel {
295 RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead 292 RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
296 }?.name 293 }?.name
297 } 294 }
295
296 func metadataInputForSave() -> [String: any Sendable] {
297 var input: [String: any Sendable] = [:]
298
299 if normalizedEditedName != repository.name {
300 input["name"] = normalizedEditedName
301 }
302
303 if normalizedEditedDescription != (repository.description ?? "") {
304 input["description"] = normalizedEditedDescription.isEmpty ? Optional<String>.none as String? : normalizedEditedDescription
305 }
306
307 return input
308 }
298} 309}
Hutch/Views/Work/WorkView.swift +8
@@ -26,6 +26,14 @@ struct WorkView: View {
26 } 26 }
27 .navigationTitle("Work") 27 .navigationTitle("Work")
28 .navigationBarTitleDisplayMode(.inline) 28 .navigationBarTitleDisplayMode(.inline)
29 .toolbar {
30 ToolbarItem(placement: .topBarTrailing) {
31 Button("Mark All Read") {
32 viewModel?.markAllInboxThreadsRead()
33 }
34 .disabled(viewModel.map { unreadCount($0) } ?? 0 == 0)
35 }
36 }
29 .task { 37 .task {
30 guard let currentUser = appState.currentUser else { return } 38 guard let currentUser = appState.currentUser else { return }
31 await ensureViewModel(currentUser: currentUser).loadDashboard() 39 await ensureViewModel(currentUser: currentUser).loadDashboard()
HutchTests/RepositorySettingsViewModelTests.swift +33
@@ -115,6 +115,39 @@ struct RepositorySettingsViewModelTests {
115 #expect(viewModel.isMetadataDirty == false) 115 #expect(viewModel.isMetadataDirty == false)
116 } 116 }
117 117
118 @Test
119 @MainActor
120 func metadataInputOmitsUnchangedNameWhenOnlyDescriptionChanges() {
121 let viewModel = RepositorySettingsViewModel(
122 repository: makeRepository(headName: "refs/heads/main"),
123 branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)],
124 client: SRHTClient(token: "test-token")
125 )
126 viewModel.editedDescription = "an ios client for sourcehut"
127
128 let input = viewModel.metadataInputForSave()
129
130 #expect(input["name"] == nil)
131 #expect(input["description"] as? String == "an ios client for sourcehut")
132 }
133
134 @Test
135 @MainActor
136 func metadataInputUsesNilToClearDescription() {
137 let viewModel = RepositorySettingsViewModel(
138 repository: makeRepository(headName: "refs/heads/main"),
139 branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)],
140 client: SRHTClient(token: "test-token")
141 )
142 viewModel.editedDescription = " "
143
144 let input = viewModel.metadataInputForSave()
145
146 #expect(input["name"] == nil)
147 #expect(input.keys.contains("description"))
148 #expect(input["description"] as? String == nil)
149 }
150
118 @MainActor 151 @MainActor
119 private func makeRepository(headName: String?) -> RepositorySummary { 152 private func makeRepository(headName: String?) -> RepositorySummary {
120 RepositorySummary( 153 RepositorySummary(