krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

109b0ea7e859de2d8b5a4dc22bf0472fd3b08eaa

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-01T17:15:23Z

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

Fixes: https://todo.sr.ht/~ccleberg/Hutch/10
 Hutch/Views/Home/HomeView.swift        | 28 ++++++++++++--------------
 Hutch/Views/Inbox/InboxView.swift      | 36 +++++++++++++++++++++++++++++-----
 Hutch/Views/Inbox/InboxViewModel.swift | 16 +++++++++++++++
 3 files changed, 60 insertions(+), 20 deletions(-)

diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index d3d370a..35e8f7c 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -26,11 +26,18 @@ struct HomeView: View {
             }
         }
         .task {
-            if viewModel == nil, let currentUser = appState.currentUser {
-                let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
-                viewModel = vm
-                await vm.loadDashboard()
+            guard let currentUser = appState.currentUser else { return }
+
+            let vm: HomeViewModel
+            if let viewModel {
+                vm = viewModel
+            } else {
+                let newViewModel = HomeViewModel(currentUser: currentUser, client: appState.client)
+                viewModel = newViewModel
+                vm = newViewModel
             }
+
+            await vm.loadDashboard()
         }
     }
 
@@ -214,17 +221,8 @@ private struct HomeInboxToolbarIcon: View {
     let hasUnreadThreads: Bool
 
     var body: some View {
-        ZStack(alignment: .topTrailing) {
-            Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
-
-            if hasUnreadThreads {
-                Circle()
-                    .fill(.blue)
-                    .frame(width: 9, height: 9)
-                    .offset(x: 4, y: -2)
-            }
-        }
-        .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
+        Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
+            .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
     }
 }
 
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index 5724964..6ca66ac 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -2,6 +2,7 @@ import SwiftUI
 
 struct InboxView: View {
     @Environment(AppState.self) private var appState
+    @Environment(\.scenePhase) private var scenePhase
     @State private var viewModel: InboxViewModel?
     @State private var selectedThreadID: InboxThreadSummary.ID?
     @State private var selectedThreadSnapshot: InboxThreadSummary?
@@ -17,10 +18,21 @@ struct InboxView: View {
         }
         .navigationTitle("Inbox")
         .task {
-            if viewModel == nil {
-                let vm = InboxViewModel(client: appState.client)
-                viewModel = vm
-                await vm.loadThreads()
+            let vm: InboxViewModel
+            if let viewModel {
+                vm = viewModel
+            } else {
+                let newViewModel = InboxViewModel(client: appState.client)
+                viewModel = newViewModel
+                vm = newViewModel
+            }
+
+            await vm.loadThreads()
+        }
+        .onChange(of: scenePhase) { _, newPhase in
+            guard newPhase == .active, let viewModel, !isShowingThreadDetail else { return }
+            Task {
+                await viewModel.loadThreads()
             }
         }
     }
@@ -51,6 +63,20 @@ struct InboxView: View {
             prompt: "Search inbox"
         )
         .listStyle(.plain)
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                if viewModel.hasUnreadThreads {
+                    Button("Mark All Read") {
+                        withAnimation(.easeInOut(duration: 0.2)) {
+                            viewModel.markAllThreadsRead()
+                        }
+                        Task {
+                            await viewModel.loadThreads()
+                        }
+                    }
+                }
+            }
+        }
         .overlay {
             if viewModel.isLoading, viewModel.threads.isEmpty {
                 SRHTLoadingStateView(message: "Loading inbox…")
@@ -66,7 +92,7 @@ struct InboxView: View {
                 ContentUnavailableView(
                     "Inbox Zero",
                     systemImage: "tray",
-                    description: Text("Unread threads will appear here.")
+                    description: Text("You're up to date.")
                 )
             }
         }
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 1d4cfd3..edb7593 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -150,6 +150,18 @@ final class InboxViewModel {
         NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
     }
 
+    func markAllThreadsRead() {
+        guard !threads.isEmpty else { return }
+
+        let viewedAt = Date()
+        for thread in threads where thread.isUnread {
+            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id)
+        }
+
+        threads.removeAll { $0.isUnread }
+        NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
+    }
+
     func markThreadUnread(_ thread: InboxThreadSummary) {
         InboxReadStateStore.markUnread(for: thread.id)
         updateThread(thread, isUnread: true)
@@ -178,6 +190,10 @@ final class InboxViewModel {
         }
     }
 
+    var hasUnreadThreads: Bool {
+        threads.contains(where: \.isUnread)
+    }
+
     private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
         var subscriptions: [InboxActivitySubscription] = []
         var cursor: String?