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 | |
| 3 | struct InboxView: View { |
3 | struct 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? |