krz/hutch

an ios client for sourcehut

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

9065ef6e92245a44390e336cc1ae515ae706cdd7

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T21:35:41Z

fix: inbox thread handling bug
 Hutch/Views/Inbox/InboxView.swift        | 87 ++++++++++++++++++++++++++++++--
 Hutch/Views/Inbox/InboxViewModel.swift   |  4 ++
 Hutch/Views/Inbox/ThreadDetailView.swift |  7 +++
 3 files changed, 94 insertions(+), 4 deletions(-)

diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index 304e62d..2b92738 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -1,8 +1,14 @@
 import SwiftUI
+import os
+
+private let inboxNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxNavigation")
 
 struct InboxView: View {
     @Environment(AppState.self) private var appState
     @State private var viewModel: InboxViewModel?
+    @State private var selectedThreadID: InboxThreadSummary.ID?
+    @State private var selectedThreadSnapshot: InboxThreadSummary?
+    @State private var isShowingThreadDetail = false
 
     var body: some View {
         Group {
@@ -28,9 +34,12 @@ struct InboxView: View {
 
         List {
             ForEach(viewModel.threads) { thread in
-                NavigationLink(value: thread) {
+                Button {
+                    selectThread(thread)
+                } label: {
                     InboxThreadRow(thread: thread)
                 }
+                .buttonStyle(.plain)
                 .swipeActions(edge: .leading, allowsFullSwipe: true) {
                     readStateAction(for: thread, in: viewModel)
                 }
@@ -64,9 +73,37 @@ struct InboxView: View {
         .refreshable {
             await viewModel.loadThreads()
         }
-        .navigationDestination(for: InboxThreadSummary.self) { thread in
-            ThreadDetailView(thread: thread) {
-                viewModel.markThreadRead(thread)
+        .onChange(of: viewModel.threads) { _, threads in
+            syncSelectedThreadSnapshot(with: threads)
+        }
+        .navigationDestination(isPresented: Binding(
+            get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil },
+            set: { isPresented in
+                if !isPresented {
+                    clearSelection()
+                }
+                isShowingThreadDetail = isPresented
+            }
+        )) {
+            if let thread = selectedThread(for: viewModel) {
+                ThreadDetailView(thread: thread) {
+                    viewModel.markThreadRead(thread)
+                }
+                .onAppear {
+                    cacheSelectedThread(thread)
+                }
+                .onDisappear {
+                    handleThreadDetailDisappear(for: thread.id)
+                }
+            } else {
+                ContentUnavailableView(
+                    "Thread Unavailable",
+                    systemImage: "tray",
+                    description: Text("This thread could not be restored.")
+                )
+                .onAppear {
+                    inboxNavigationLogger.error("Inbox navigation destination missing thread snapshot")
+                }
             }
         }
     }
@@ -82,6 +119,48 @@ struct InboxView: View {
         }
         .tint(.blue)
     }
+
+    private func selectThread(_ thread: InboxThreadSummary) {
+        cacheSelectedThread(thread)
+        isShowingThreadDetail = true
+        inboxNavigationLogger.debug(
+            "Inbox navigation triggered: threadID=\(thread.id, privacy: .public) subject=\(thread.subject, privacy: .public)"
+        )
+    }
+
+    private func cacheSelectedThread(_ thread: InboxThreadSummary) {
+        selectedThreadID = thread.id
+        selectedThreadSnapshot = thread
+    }
+
+    private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? {
+        guard let selectedThreadID else { return selectedThreadSnapshot }
+        return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil)
+    }
+
+    private func handleThreadDetailDisappear(for threadID: String) {
+        let isActiveSelection = selectedThreadID == threadID
+        inboxNavigationLogger.debug(
+            "Inbox thread detail disappeared: threadID=\(threadID, privacy: .public) activeSelection=\(isActiveSelection, privacy: .public)"
+        )
+        guard isActiveSelection else { return }
+        clearSelection()
+    }
+
+    private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) {
+        guard let selectedThreadID else { return }
+        guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return }
+        selectedThreadSnapshot = updatedThread
+    }
+
+    private func clearSelection() {
+        if let selectedThreadID {
+            inboxNavigationLogger.debug("Inbox selection cleared: threadID=\(selectedThreadID, privacy: .public)")
+        }
+        selectedThreadID = nil
+        selectedThreadSnapshot = nil
+        isShowingThreadDetail = false
+    }
 }
 
 struct InboxThreadRow: View {
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 9c1ef45..e4a0664 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -166,6 +166,10 @@ final class InboxViewModel {
         }
     }
 
+    func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
+        threads.first(where: { $0.id == id })
+    }
+
     private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
         var subscriptions: [InboxActivitySubscription] = []
         var cursor: String?
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
index 6fe835c..c5d26a5 100644
--- a/Hutch/Views/Inbox/ThreadDetailView.swift
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -4,6 +4,7 @@ import SwiftUI
 import UIKit
 
 private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
+private let inboxThreadNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxThreadNavigation")
 
 struct ThreadDetailView: View {
     let thread: InboxThreadSummary
@@ -52,12 +53,18 @@ struct ThreadDetailView: View {
             isUnread = thread.isUnread
             await vm.loadThread()
         }
+        .onAppear {
+            inboxThreadNavigationLogger.debug("Inbox thread detail appeared: threadID=\(thread.id, privacy: .public)")
+        }
         .onChange(of: viewModel?.thread?.id) { _, threadID in
             guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
             hasMarkedCurrentThreadViewed = true
             isUnread = false
             onViewed()
         }
+        .onDisappear {
+            inboxThreadNavigationLogger.debug("Inbox thread detail view disappeared: threadID=\(thread.id, privacy: .public)")
+        }
         .sheet(item: Binding(
             get: { viewModel?.composeDraft },
             set: { _ in viewModel?.dismissReply() }