Commit d151e20a46

d151e20a46f18addc04dcc976dd41307266d7e6d

parent: 9d4296fb07

Unsigned

cmc <hello@cleberg.net> · 2026-04-01 17:15 UTC

fix: fixed inbox's inconsistent UX and adds a Mark All Read button

Fixes: https://todo.sr.ht/~ccleberg/Hutch/10

Layout: unified · split

Hutch/Views/Home/HomeView.swift +13 −15
@@ -26,11 +26,18 @@ struct HomeView: View {
26 } 26 }
27 } 27 }
28 .task { 28 .task {
29 if viewModel == nil, let currentUser = appState.currentUser { 29 guard let currentUser = appState.currentUser else { return }
30 let vm = HomeViewModel(currentUser: currentUser, client: appState.client) 30
31 viewModel = vm 31 let vm: HomeViewModel
32 await vm.loadDashboard() 32 if let viewModel {
33 vm = viewModel
34 } else {
35 let newViewModel = HomeViewModel(currentUser: currentUser, client: appState.client)
36 viewModel = newViewModel
37 vm = newViewModel
33 } 38 }
39
40 await vm.loadDashboard()
34 } 41 }
35 } 42 }
36 43
@@ -214,17 +221,8 @@ private struct HomeInboxToolbarIcon: View {
214 let hasUnreadThreads: Bool 221 let hasUnreadThreads: Bool
215 222
216 var body: some View { 223 var body: some View {
217 ZStack(alignment: .topTrailing) { 224 Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
218 Image(systemName: hasUnreadThreads ? "tray.fill" : "tray") 225 .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
219
220 if hasUnreadThreads {
221 Circle()
222 .fill(.blue)
223 .frame(width: 9, height: 9)
224 .offset(x: 4, y: -2)
225 }
226 }
227 .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
228 } 226 }
229} 227}
230 228
Hutch/Views/Inbox/InboxView.swift +31 −5
@@ -2,6 +2,7 @@ import SwiftUI
2 2
3struct InboxView: View { 3struct InboxView: View {
4 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
5 @Environment(\.scenePhase) private var scenePhase
5 @State private var viewModel: InboxViewModel? 6 @State private var viewModel: InboxViewModel?
6 @State private var selectedThreadID: InboxThreadSummary.ID? 7 @State private var selectedThreadID: InboxThreadSummary.ID?
7 @State private var selectedThreadSnapshot: InboxThreadSummary? 8 @State private var selectedThreadSnapshot: InboxThreadSummary?
@@ -17,10 +18,21 @@ struct InboxView: View {
17 } 18 }
18 .navigationTitle("Inbox") 19 .navigationTitle("Inbox")
19 .task { 20 .task {
20 if viewModel == nil { 21 let vm: InboxViewModel
21 let vm = InboxViewModel(client: appState.client) 22 if let viewModel {
22 viewModel = vm 23 vm = viewModel
23 await vm.loadThreads() 24 } else {
25 let newViewModel = InboxViewModel(client: appState.client)
26 viewModel = newViewModel
27 vm = newViewModel
28 }
29
30 await vm.loadThreads()
31 }
32 .onChange(of: scenePhase) { _, newPhase in
33 guard newPhase == .active, let viewModel, !isShowingThreadDetail else { return }
34 Task {
35 await viewModel.loadThreads()
24 } 36 }
25 } 37 }
26 } 38 }
@@ -51,6 +63,20 @@ struct InboxView: View {
51 prompt: "Search inbox" 63 prompt: "Search inbox"
52 ) 64 )
53 .listStyle(.plain) 65 .listStyle(.plain)
66 .toolbar {
67 ToolbarItem(placement: .topBarTrailing) {
68 if viewModel.hasUnreadThreads {
69 Button("Mark All Read") {
70 withAnimation(.easeInOut(duration: 0.2)) {
71 viewModel.markAllThreadsRead()
72 }
73 Task {
74 await viewModel.loadThreads()
75 }
76 }
77 }
78 }
79 }
54 .overlay { 80 .overlay {
55 if viewModel.isLoading, viewModel.threads.isEmpty { 81 if viewModel.isLoading, viewModel.threads.isEmpty {
56 SRHTLoadingStateView(message: "Loading inbox…") 82 SRHTLoadingStateView(message: "Loading inbox…")
@@ -66,7 +92,7 @@ struct InboxView: View {
66 ContentUnavailableView( 92 ContentUnavailableView(
67 "Inbox Zero", 93 "Inbox Zero",
68 systemImage: "tray", 94 systemImage: "tray",
69 description: Text("Unread threads will appear here.") 95 description: Text("You're up to date.")
70 ) 96 )
71 } 97 }
72 } 98 }
Hutch/Views/Inbox/InboxViewModel.swift +16
@@ -150,6 +150,18 @@ final class InboxViewModel {
150 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 150 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
151 } 151 }
152 152
153 func markAllThreadsRead() {
154 guard !threads.isEmpty else { return }
155
156 let viewedAt = Date()
157 for thread in threads where thread.isUnread {
158 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id)
159 }
160
161 threads.removeAll { $0.isUnread }
162 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
163 }
164
153 func markThreadUnread(_ thread: InboxThreadSummary) { 165 func markThreadUnread(_ thread: InboxThreadSummary) {
154 InboxReadStateStore.markUnread(for: thread.id) 166 InboxReadStateStore.markUnread(for: thread.id)
155 updateThread(thread, isUnread: true) 167 updateThread(thread, isUnread: true)
@@ -178,6 +190,10 @@ final class InboxViewModel {
178 } 190 }
179 } 191 }
180 192
193 var hasUnreadThreads: Bool {
194 threads.contains(where: \.isUnread)
195 }
196
181 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { 197 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
182 var subscriptions: [InboxActivitySubscription] = [] 198 var subscriptions: [InboxActivitySubscription] = []
183 var cursor: String? 199 var cursor: String?