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 @@
1import SwiftUI 1import SwiftUI
2import os
3
4private let inboxNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxNavigation")
2 5
3struct InboxView: View { 6struct InboxView: View {
4 @Environment(AppState.self) private var appState 7 @Environment(AppState.self) private var appState
5 @State private var viewModel: InboxViewModel? 8 @State private var viewModel: InboxViewModel?
9 @State private var selectedThreadID: InboxThreadSummary.ID?
10 @State private var selectedThreadSnapshot: InboxThreadSummary?
11 @State private var isShowingThreadDetail = false
6 12
7 var body: some View { 13 var body: some View {
8 Group { 14 Group {
@@ -28,9 +34,12 @@ struct InboxView: View {
28 34
29 List { 35 List {
30 ForEach(viewModel.threads) { thread in 36 ForEach(viewModel.threads) { thread in
31 NavigationLink(value: thread) { 37 Button {
38 selectThread(thread)
39 } label: {
32 InboxThreadRow(thread: thread) 40 InboxThreadRow(thread: thread)
33 } 41 }
42 .buttonStyle(.plain)
34 .swipeActions(edge: .leading, allowsFullSwipe: true) { 43 .swipeActions(edge: .leading, allowsFullSwipe: true) {
35 readStateAction(for: thread, in: viewModel) 44 readStateAction(for: thread, in: viewModel)
36 } 45 }
@@ -64,9 +73,37 @@ struct InboxView: View {
64 .refreshable { 73 .refreshable {
65 await viewModel.loadThreads() 74 await viewModel.loadThreads()
66 } 75 }
67 .navigationDestination(for: InboxThreadSummary.self) { thread in 76 .onChange(of: viewModel.threads) { _, threads in
68 ThreadDetailView(thread: thread) { 77 syncSelectedThreadSnapshot(with: threads)
69 viewModel.markThreadRead(thread) 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 }
70 } 107 }
71 } 108 }
72 } 109 }
@@ -82,6 +119,48 @@ struct InboxView: View {
82 } 119 }
83 .tint(.blue) 120 .tint(.blue)
84 } 121 }
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 }
85} 164}
86 165
87struct InboxThreadRow: View { 166struct InboxThreadRow: View {
Hutch/Views/Inbox/InboxViewModel.swift +4
@@ -166,6 +166,10 @@ final class InboxViewModel {
166 } 166 }
167 } 167 }
168 168
169 func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
170 threads.first(where: { $0.id == id })
171 }
172
169 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { 173 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
170 var subscriptions: [InboxActivitySubscription] = [] 174 var subscriptions: [InboxActivitySubscription] = []
171 var cursor: String? 175 var cursor: String?
Hutch/Views/Inbox/ThreadDetailView.swift +7
@@ -4,6 +4,7 @@ import SwiftUI
4import UIKit 4import UIKit
5 5
6private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply") 6private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
7private let inboxThreadNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxThreadNavigation")
7 8
8struct ThreadDetailView: View { 9struct ThreadDetailView: View {
9 let thread: InboxThreadSummary 10 let thread: InboxThreadSummary
@@ -52,12 +53,18 @@ struct ThreadDetailView: View {
52 isUnread = thread.isUnread 53 isUnread = thread.isUnread
53 await vm.loadThread() 54 await vm.loadThread()
54 } 55 }
56 .onAppear {
57 inboxThreadNavigationLogger.debug("Inbox thread detail appeared: threadID=\(thread.id, privacy: .public)")
58 }
55 .onChange(of: viewModel?.thread?.id) { _, threadID in 59 .onChange(of: viewModel?.thread?.id) { _, threadID in
56 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return } 60 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
57 hasMarkedCurrentThreadViewed = true 61 hasMarkedCurrentThreadViewed = true
58 isUnread = false 62 isUnread = false
59 onViewed() 63 onViewed()
60 } 64 }
65 .onDisappear {
66 inboxThreadNavigationLogger.debug("Inbox thread detail view disappeared: threadID=\(thread.id, privacy: .public)")
67 }
61 .sheet(item: Binding( 68 .sheet(item: Binding(
62 get: { viewModel?.composeDraft }, 69 get: { viewModel?.composeDraft },
63 set: { _ in viewModel?.dismissReply() } 70 set: { _ in viewModel?.dismissReply() }