Commit 7718ac2b7f

7718ac2b7ff58146e71dd065cd81cbb5beea512a

parent: 34e12c4d2c

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 00:30 UTC

feat: improve inbox reply flow and basic threading

Implements: https://todo.sr.ht/~ccleberg/hutch/23
Implements: https://todo.sr.ht/~ccleberg/hutch/34

Layout: unified · split

Hutch/Views/Inbox/ThreadDetailView.swift +64 −29
@@ -19,6 +19,7 @@ struct ThreadDetailView: View {
19 @State private var suppressAutoMarkViewed = false 19 @State private var suppressAutoMarkViewed = false
20 @State private var isUnread: Bool 20 @State private var isUnread: Bool
21 @State private var isOpeningRepository = false 21 @State private var isOpeningRepository = false
22 @State private var collapsedMessageIDs: Set<Int> = []
22 23
23 init( 24 init(
24 thread: InboxThreadSummary, 25 thread: InboxThreadSummary,
@@ -51,7 +52,11 @@ struct ThreadDetailView: View {
51 hasMarkedCurrentThreadViewed = false 52 hasMarkedCurrentThreadViewed = false
52 suppressAutoMarkViewed = false 53 suppressAutoMarkViewed = false
53 isUnread = thread.isUnread 54 isUnread = thread.isUnread
55 collapsedMessageIDs = []
54 await vm.loadThread() 56 await vm.loadThread()
57 if let messages = vm.thread?.messages, messages.count > 1 {
58 collapsedMessageIDs = Set(messages.dropLast().map(\.id))
59 }
55 } 60 }
56 .onChange(of: viewModel?.thread?.id) { _, threadID in 61 .onChange(of: viewModel?.thread?.id) { _, threadID in
57 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return } 62 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
@@ -164,7 +169,20 @@ struct ThreadDetailView: View {
164 } 169 }
165 170
166 ForEach(thread.messages) { message in 171 ForEach(thread.messages) { message in
167 InboxMessageRow(message: message) 172 InboxMessageRow(
173 message: message,
174 isCollapsed: collapsedMessageIDs.contains(message.id),
175 canCollapse: thread.messages.count > 1,
176 onToggleCollapse: {
177 withAnimation(.easeInOut(duration: 0.2)) {
178 if collapsedMessageIDs.contains(message.id) {
179 collapsedMessageIDs.remove(message.id)
180 } else {
181 collapsedMessageIDs.insert(message.id)
182 }
183 }
184 }
185 )
168 } 186 }
169 } 187 }
170 } 188 }
@@ -235,43 +253,60 @@ struct ThreadDetailView: View {
235 253
236private struct InboxMessageRow: View { 254private struct InboxMessageRow: View {
237 let message: InboxMessage 255 let message: InboxMessage
256 var isCollapsed: Bool = false
257 var canCollapse: Bool = false
258 var onToggleCollapse: (() -> Void)? = nil
238 259
239 var body: some View { 260 var body: some View {
240 VStack(alignment: .leading, spacing: 10) { 261 VStack(alignment: .leading, spacing: isCollapsed ? 0 : 10) {
241 HStack(alignment: .top, spacing: 12) { 262 Button {
242 VStack(alignment: .leading, spacing: 2) { 263 onToggleCollapse?()
243 Text(senderLine) 264 } label: {
244 .font(.subheadline.weight(.medium)) 265 HStack(alignment: .top, spacing: 12) {
245 .lineLimit(2) 266 if canCollapse {
246 Text(message.date.formatted(date: .abbreviated, time: .shortened)) 267 Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
247 .font(.caption) 268 .font(.caption)
248 .foregroundStyle(.secondary) 269 .foregroundStyle(.tertiary)
249 } 270 .padding(.top, 4)
271 }
250 272
251 Spacer() 273 VStack(alignment: .leading, spacing: 2) {
274 Text(isCollapsed ? message.senderDisplayName : senderLine)
275 .font(.subheadline.weight(.medium))
276 .lineLimit(isCollapsed ? 1 : 2)
277 Text(message.date.formatted(date: .abbreviated, time: .shortened))
278 .font(.caption)
279 .foregroundStyle(.secondary)
280 }
281
282 Spacer()
252 283
253 if message.isPatch { 284 if message.isPatch {
254 Text("Patch") 285 Text("Patch")
255 .font(.caption2.weight(.medium)) 286 .font(.caption2.weight(.medium))
256 .foregroundStyle(.secondary) 287 .foregroundStyle(.secondary)
288 }
257 } 289 }
258 } 290 }
259 291 .buttonStyle(.plain)
260 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in 292
261 switch block { 293 if !isCollapsed {
262 case .plainText(let text): 294 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
263 Text(text) 295 switch block {
264 .font(.body) 296 case .plainText(let text):
265 .textSelection(.enabled) 297 Text(text)
266 .frame(maxWidth: .infinity, alignment: .leading) 298 .font(.body)
267 .fixedSize(horizontal: false, vertical: true) 299 .textSelection(.enabled)
268 case .diff(let diff): 300 .frame(maxWidth: .infinity, alignment: .leading)
269 DiffView(diff: diff) 301 .fixedSize(horizontal: false, vertical: true)
270 .textSelection(.enabled) 302 case .diff(let diff):
303 DiffView(diff: diff)
304 .textSelection(.enabled)
305 }
271 } 306 }
272 } 307 }
273 } 308 }
274 .padding(.vertical, 6) 309 .padding(.vertical, isCollapsed ? 4 : 6)
275 .listRowSeparator(.visible) 310 .listRowSeparator(.visible)
276 } 311 }
277 312