diff options
| author | Christian Cleberg <[email protected]> | 2026-04-20 13:04:57 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-20 13:04:57 -0500 |
| commit | 8b73fd9cd43753b36b3ab3ca5e4dc83a47ea3480 (patch) | |
| tree | 25658dbdc28775764f5745576c240efd38fd4685 | |
| parent | d29fbef60abdffaee190b758530dde9212745ed2 (diff) | |
| download | hutch-8b73fd9cd43753b36b3ab3ca5e4dc83a47ea3480.tar.gz hutch-8b73fd9cd43753b36b3ab3ca5e4dc83a47ea3480.tar.bz2 hutch-8b73fd9cd43753b36b3ab3ca5e4dc83a47ea3480.zip | |
fix: add mark all read actions and avoid resending unchanged repo metadata
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 14 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 52 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 19 | ||||
| -rw-r--r-- | Hutch/Views/Work/WorkView.swift | 8 | ||||
| -rw-r--r-- | 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( |
