Commit af758cc55f

af758cc55f7032f76bef4c5ae2d6472fa05d0f84

parent: 5d64fcf7f4

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 21:35 UTC

fix: inbox thread handling bug

Layout: unified · split

Hutch/Views/Inbox/InboxView.swift +83 −4
@@ -1,8 +1,14 @@
11import SwiftUI
2import os
3
4private let inboxNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxNavigation")
25
36struct InboxView: View {
47 @Environment(AppState.self) private var appState
58 @State private var viewModel: InboxViewModel?
9 @State private var selectedThreadID: InboxThreadSummary.ID?
10 @State private var selectedThreadSnapshot: InboxThreadSummary?
11 @State private var isShowingThreadDetail = false
612
713 var body: some View {
814 Group {
@@ -28,9 +34,12 @@ struct InboxView: View {
2834
2935 List {
3036 ForEach(viewModel.threads) { thread in
31 NavigationLink(value: thread) {
37 Button {
38 selectThread(thread)
39 } label: {
3240 InboxThreadRow(thread: thread)
3341 }
42 .buttonStyle(.plain)
3443 .swipeActions(edge: .leading, allowsFullSwipe: true) {
3544 readStateAction(for: thread, in: viewModel)
3645 }
@@ -64,9 +73,37 @@ struct InboxView: View {
6473 .refreshable {
6574 await viewModel.loadThreads()
6675 }
67 .navigationDestination(for: InboxThreadSummary.self) { thread in
68 ThreadDetailView(thread: thread) {
69 viewModel.markThreadRead(thread)
76 .onChange(of: viewModel.threads) { _, threads in
77 syncSelectedThreadSnapshot(with: threads)
78 }
79 .navigationDestination(isPresented: Binding(
80 get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil },
81 set: { isPresented in
82 if !isPresented {
83 clearSelection()
84 }
85 isShowingThreadDetail = isPresented
86 }
87 )) {
88 if let thread = selectedThread(for: viewModel) {
89 ThreadDetailView(thread: thread) {
90 viewModel.markThreadRead(thread)
91 }
92 .onAppear {
93 cacheSelectedThread(thread)
94 }
95 .onDisappear {
96 handleThreadDetailDisappear(for: thread.id)
97 }
98 } else {
99 ContentUnavailableView(
100 "Thread Unavailable",
101 systemImage: "tray",
102 description: Text("This thread could not be restored.")
103 )
104 .onAppear {
105 inboxNavigationLogger.error("Inbox navigation destination missing thread snapshot")
106 }
70107 }
71108 }
72109 }
@@ -82,6 +119,48 @@ struct InboxView: View {
82119 }
83120 .tint(.blue)
84121 }
122
123 private func selectThread(_ thread: InboxThreadSummary) {
124 cacheSelectedThread(thread)
125 isShowingThreadDetail = true
126 inboxNavigationLogger.debug(
127 "Inbox navigation triggered: threadID=\(thread.id, privacy: .public) subject=\(thread.subject, privacy: .public)"
128 )
129 }
130
131 private func cacheSelectedThread(_ thread: InboxThreadSummary) {
132 selectedThreadID = thread.id
133 selectedThreadSnapshot = thread
134 }
135
136 private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? {
137 guard let selectedThreadID else { return selectedThreadSnapshot }
138 return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil)
139 }
140
141 private func handleThreadDetailDisappear(for threadID: String) {
142 let isActiveSelection = selectedThreadID == threadID
143 inboxNavigationLogger.debug(
144 "Inbox thread detail disappeared: threadID=\(threadID, privacy: .public) activeSelection=\(isActiveSelection, privacy: .public)"
145 )
146 guard isActiveSelection else { return }
147 clearSelection()
148 }
149
150 private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) {
151 guard let selectedThreadID else { return }
152 guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return }
153 selectedThreadSnapshot = updatedThread
154 }
155
156 private func clearSelection() {
157 if let selectedThreadID {
158 inboxNavigationLogger.debug("Inbox selection cleared: threadID=\(selectedThreadID, privacy: .public)")
159 }
160 selectedThreadID = nil
161 selectedThreadSnapshot = nil
162 isShowingThreadDetail = false
163 }
85164}
86165
87166struct InboxThreadRow: View {
Hutch/Views/Inbox/InboxViewModel.swift +4
@@ -166,6 +166,10 @@ final class InboxViewModel {
166166 }
167167 }
168168
169 func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
170 threads.first(where: { $0.id == id })
171 }
172
169173 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
170174 var subscriptions: [InboxActivitySubscription] = []
171175 var cursor: String?
Hutch/Views/Inbox/ThreadDetailView.swift +7
@@ -4,6 +4,7 @@ import SwiftUI
44import UIKit
55
66private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
7private let inboxThreadNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxThreadNavigation")
78
89struct ThreadDetailView: View {
910 let thread: InboxThreadSummary
@@ -52,12 +53,18 @@ struct ThreadDetailView: View {
5253 isUnread = thread.isUnread
5354 await vm.loadThread()
5455 }
56 .onAppear {
57 inboxThreadNavigationLogger.debug("Inbox thread detail appeared: threadID=\(thread.id, privacy: .public)")
58 }
5559 .onChange(of: viewModel?.thread?.id) { _, threadID in
5660 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
5761 hasMarkedCurrentThreadViewed = true
5862 isUnread = false
5963 onViewed()
6064 }
65 .onDisappear {
66 inboxThreadNavigationLogger.debug("Inbox thread detail view disappeared: threadID=\(thread.id, privacy: .public)")
67 }
6168 .sheet(item: Binding(
6269 get: { viewModel?.composeDraft },
6370 set: { _ in viewModel?.dismissReply() }