Commit 134442898a
Unsigned
Layout: unified · split
Hutch/Views/Home/HomeViewModel.swift +14
| @@ -627,6 +627,20 @@ final class HomeViewModel { | ||
| 627 | 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 | 644 | func markInboxThreadUnread(_ thread: InboxThreadSummary) { |
| 631 | 645 | InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) |
| 632 | 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 | 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 | 141 | private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { |
| 109 | 142 | let normalizedSubject = thread.subject |
| 110 | 143 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) |
| @@ -241,6 +274,10 @@ struct MailingListDetailView: View { | ||
| 241 | 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 | 281 | var body: some View { |
| 245 | 282 | Group { |
| 246 | 283 | if let viewModel { |
| @@ -252,6 +289,12 @@ struct MailingListDetailView: View { | ||
| 252 | 289 | .navigationTitle(mailingList.name) |
| 253 | 290 | .navigationBarTitleDisplayMode(.inline) |
| 254 | 291 | .toolbar { |
| 292 | ToolbarItem(placement: .topBarTrailing) { | |
| 293 | Button("Mark All Read") { | |
| 294 | viewModel?.markAllThreadsRead() | |
| 295 | } | |
| 296 | .disabled(hasUnreadThreads == false) | |
| 297 | } | |
| 255 | 298 | if currentUserKey != nil { |
| 256 | 299 | ToolbarItem(placement: .topBarTrailing) { |
| 257 | 300 | Button { |
| @@ -299,16 +342,13 @@ struct MailingListDetailView: View { | ||
| 299 | 342 | ThreadDetailView( |
| 300 | 343 | thread: thread, |
| 301 | 344 | onViewed: { |
| 302 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) | |
| 303 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) | |
| 345 | viewModel.markThreadRead(thread) | |
| 304 | 346 | }, |
| 305 | 347 | onMarkRead: { |
| 306 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) | |
| 307 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) | |
| 348 | viewModel.markThreadRead(thread) | |
| 308 | 349 | }, |
| 309 | 350 | onMarkUnread: { |
| 310 | InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults) | |
| 311 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID) | |
| 351 | viewModel.markThreadUnread(thread) | |
| 312 | 352 | } |
| 313 | 353 | ) |
| 314 | 354 | } label: { |
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +15 −4
| @@ -166,10 +166,7 @@ final class RepositorySettingsViewModel { | ||
| 166 | 166 | defer { isSavingMetadata = false } |
| 167 | 167 | error = nil |
| 168 | 168 | |
| 169 | let input: [String: any Sendable] = [ | |
| 170 | "name": normalizedEditedName, | |
| 171 | "description": normalizedEditedDescription | |
| 172 | ] | |
| 169 | let input = metadataInputForSave() | |
| 173 | 170 | |
| 174 | 171 | do { |
| 175 | 172 | return try await updateRepository(with: input) |
| @@ -295,4 +292,18 @@ final class RepositorySettingsViewModel { | ||
| 295 | 292 | RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead |
| 296 | 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 | 27 | .navigationTitle("Work") |
| 28 | 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 | 37 | .task { |
| 30 | 38 | guard let currentUser = appState.currentUser else { return } |
| 31 | 39 | await ensureViewModel(currentUser: currentUser).loadDashboard() |
HutchTests/RepositorySettingsViewModelTests.swift +33
| @@ -115,6 +115,39 @@ struct RepositorySettingsViewModelTests { | ||
| 115 | 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 | 151 | @MainActor |
| 119 | 152 | private func makeRepository(headName: String?) -> RepositorySummary { |
| 120 | 153 | RepositorySummary( |