krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: Hutch/Views/Inbox/ThreadDetailView.swift · raw
1import MessageUI
2import os
3import SwiftUI
4import UIKit
5
6private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
7
8struct ThreadDetailView: View {
9 let thread: InboxThreadSummary
10 let onViewed: () -> Void
11 var onMarkRead: (() -> Void)? = nil
12 var onMarkUnread: (() -> Void)? = nil
13
14 @Environment(AppState.self) private var appState
15 @State private var viewModel: ThreadViewModel?
16 @State private var replySuccessMessage: String?
17 @State private var loadedThreadID: String?
18 @State private var hasMarkedCurrentThreadViewed = false
19 @State private var suppressAutoMarkViewed = false
20 @State private var isUnread: Bool
21 @State private var isOpeningRepository = false
22 @State private var collapsedMessageIDs: Set<Int> = []
23
24 init(
25 thread: InboxThreadSummary,
26 onViewed: @escaping () -> Void,
27 onMarkRead: (() -> Void)? = nil,
28 onMarkUnread: (() -> Void)? = nil
29 ) {
30 self.thread = thread
31 self.onViewed = onViewed
32 self.onMarkRead = onMarkRead
33 self.onMarkUnread = onMarkUnread
34 self._isUnread = State(initialValue: thread.isUnread)
35 }
36
37 var body: some View {
38 Group {
39 if let viewModel {
40 content(viewModel)
41 } else {
42 SRHTLoadingStateView(message: "Loading thread…")
43 }
44 }
45 .navigationTitle("Thread")
46 .navigationBarTitleDisplayMode(.inline)
47 .task(id: thread.id) {
48 guard loadedThreadID != thread.id else { return }
49 let vm = ThreadViewModel(summary: thread, client: appState.client)
50 viewModel = vm
51 loadedThreadID = thread.id
52 hasMarkedCurrentThreadViewed = false
53 suppressAutoMarkViewed = false
54 isUnread = thread.isUnread
55 collapsedMessageIDs = []
56 await vm.loadThread()
57 if let messages = vm.thread?.messages, messages.count > 1 {
58 collapsedMessageIDs = Set(messages.dropLast().map(\.id))
59 }
60 }
61 .onChange(of: viewModel?.thread?.id) { _, threadID in
62 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
63 hasMarkedCurrentThreadViewed = true
64 isUnread = false
65 onViewed()
66 }
67 .sheet(item: Binding(
68 get: { viewModel?.composeDraft },
69 set: { _ in viewModel?.dismissReply() }
70 )) { draft in
71 MailComposeView(draft: draft) { result in
72 switch result {
73 case .failed(let message):
74 inboxReplyLogger.error("Inbox reply failed")
75 viewModel?.error = message
76 case .cancelled:
77 break
78 case .saved:
79 break
80 case .sent:
81 replySuccessMessage = "Reply handed off to Mail."
82 Task {
83 await viewModel?.loadThread()
84 }
85 }
86 }
87 }
88 .overlay(alignment: .top) {
89 if let replySuccessMessage {
90 Text(replySuccessMessage)
91 .font(.caption.weight(.medium))
92 .padding(.horizontal, 12)
93 .padding(.vertical, 8)
94 .background(.thinMaterial, in: Capsule())
95 .padding(.top, 8)
96 .transition(.move(edge: .top).combined(with: .opacity))
97 }
98 }
99 .animation(.easeInOut(duration: 0.2), value: replySuccessMessage)
100 .onChange(of: replySuccessMessage) { _, message in
101 guard message != nil else { return }
102 Task { @MainActor in
103 try? await Task.sleep(for: .seconds(2))
104 if self.replySuccessMessage == message {
105 self.replySuccessMessage = nil
106 }
107 }
108 }
109 }
110
111 @ViewBuilder
112 private func content(_ viewModel: ThreadViewModel) -> some View {
113 @Bindable var vm = viewModel
114
115 List {
116 if let thread = viewModel.thread {
117 Section {
118 VStack(alignment: .leading, spacing: 6) {
119 Text(thread.displaySubject)
120 .font(.headline)
121 Text(headerMetadata(thread))
122 .font(.caption)
123 .foregroundStyle(.secondary)
124 }
125 .padding(.vertical, 4)
126 .themedRow()
127 }
128
129 Section("Related") {
130 Button {
131 appState.navigateToMailingList(
132 InboxMailingListReference(
133 id: thread.listID,
134 rid: thread.listRID,
135 name: thread.listName,
136 owner: thread.listOwner
137 )
138 )
139 } label: {
140 Label(thread.listDisplayName, systemImage: "list.bullet")
141 }
142 .themedRow()
143
144 if let repo = self.thread.repo {
145 Button {
146 openRepository(named: repo, ownerCanonicalName: thread.listOwner.canonicalName)
147 } label: {
148 HStack {
149 Label("\(thread.listOwner.canonicalName)/\(repo)", systemImage: "book.closed")
150 Spacer()
151 if isOpeningRepository {
152 ProgressView()
153 .controlSize(.small)
154 } else {
155 Image(systemName: "arrow.up.right")
156 .font(.caption)
157 .foregroundStyle(.tertiary)
158 }
159 }
160 }
161 .disabled(isOpeningRepository)
162 .themedRow()
163 }
164 }
165
166 if let partialWarning = viewModel.partialWarning {
167 Section {
168 Text(partialWarning)
169 .font(.caption)
170 .foregroundStyle(.secondary)
171 .themedRow()
172 }
173 }
174
175 ForEach(thread.messages) { message in
176 InboxMessageRow(
177 message: message,
178 isCollapsed: collapsedMessageIDs.contains(message.id),
179 canCollapse: thread.messages.count > 1,
180 onToggleCollapse: {
181 withAnimation(.easeInOut(duration: 0.2)) {
182 if collapsedMessageIDs.contains(message.id) {
183 collapsedMessageIDs.remove(message.id)
184 } else {
185 collapsedMessageIDs.insert(message.id)
186 }
187 }
188 }
189 )
190 }
191 .themedRow()
192 }
193 }
194 .themedList()
195 .listStyle(.plain)
196 .toolbar {
197 ToolbarItem(placement: .topBarTrailing) {
198 HStack {
199 if onMarkRead != nil || onMarkUnread != nil {
200 Button(isUnread ? "Mark Read" : "Mark Unread") {
201 suppressAutoMarkViewed = !isUnread
202 if isUnread {
203 onMarkRead?()
204 isUnread = false
205 } else {
206 onMarkUnread?()
207 isUnread = true
208 }
209 }
210 }
211
212 Button("Reply") {
213 viewModel.prepareReply()
214 }
215 }
216 }
217 }
218 .overlay {
219 if viewModel.isLoading, viewModel.thread == nil {
220 SRHTLoadingStateView(message: "Loading thread…")
221 } else if let error = viewModel.error, viewModel.thread == nil {
222 SRHTErrorStateView(
223 title: "Failed to load thread",
224 message: error,
225 retryAction: { await viewModel.loadThread() }
226 )
227 }
228 }
229 .srhtErrorBanner(error: $vm.error)
230 .refreshable {
231 await viewModel.loadThread()
232 }
233 }
234
235 private func headerMetadata(_ thread: InboxThreadDetail) -> String {
236 var parts = [thread.listDisplayName]
237 if let messageCount = thread.messageCount, messageCount > 1 {
238 parts.append("\(messageCount) messages")
239 }
240 parts.append(thread.lastActivityAt.relativeDescription)
241 return parts.joined(separator: " • ")
242 }
243
244 private func openRepository(named repositoryName: String, ownerCanonicalName: String) {
245 guard !isOpeningRepository else { return }
246 isOpeningRepository = true
247 Task {
248 defer { isOpeningRepository = false }
249 do {
250 let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
251 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
252 appState.navigateToRepository(repository)
253 } catch {
254 appState.presentRepositoryDeepLinkError()
255 }
256 }
257 }
258}
259
260private struct InboxMessageRow: View {
261 let message: InboxMessage
262 var isCollapsed: Bool = false
263 var canCollapse: Bool = false
264 var onToggleCollapse: (() -> Void)? = nil
265
266 var body: some View {
267 VStack(alignment: .leading, spacing: isCollapsed ? 0 : 10) {
268 Button {
269 onToggleCollapse?()
270 } label: {
271 HStack(alignment: .top, spacing: 12) {
272 if canCollapse {
273 Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
274 .font(.caption)
275 .foregroundStyle(.tertiary)
276 .padding(.top, 4)
277 }
278
279 VStack(alignment: .leading, spacing: 2) {
280 Text(isCollapsed ? message.senderDisplayName : senderLine)
281 .font(.subheadline.weight(.medium))
282 .lineLimit(isCollapsed ? 1 : 2)
283 Text(message.date.formatted(date: .abbreviated, time: .shortened))
284 .font(.caption)
285 .foregroundStyle(.secondary)
286 }
287
288 Spacer()
289
290 if message.isPatch {
291 Text("Patch")
292 .font(.caption2.weight(.medium))
293 .foregroundStyle(.secondary)
294 }
295 }
296 }
297 .buttonStyle(.plain)
298
299 if !isCollapsed {
300 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
301 switch block {
302 case .plainText(let text):
303 Text(text)
304 .font(.body)
305 .textSelection(.enabled)
306 .frame(maxWidth: .infinity, alignment: .leading)
307 .fixedSize(horizontal: false, vertical: true)
308 case .diff(let diff):
309 DiffView(diff: diff)
310 .textSelection(.enabled)
311 }
312 }
313 }
314 }
315 .padding(.vertical, isCollapsed ? 4 : 6)
316 .listRowSeparator(.visible)
317 }
318
319 private var senderLine: String {
320 if let email = message.senderEmailAddress,
321 email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame {
322 return "\(message.senderDisplayName) <\(email)>"
323 }
324 return message.senderDisplayName
325 }
326}
327
328private struct MailComposeView: UIViewControllerRepresentable {
329 let draft: MailComposeDraft
330 let onComplete: (Result) -> Void
331
332 enum Result {
333 case cancelled
334 case saved
335 case sent
336 case failed(String)
337 }
338
339 func makeCoordinator() -> Coordinator {
340 Coordinator(onComplete: onComplete)
341 }
342
343 func makeUIViewController(context: Context) -> UIViewController {
344 guard MFMailComposeViewController.canSendMail() else {
345 let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: {
346 context.coordinator.onComplete(.failed("Mail is not configured on this device."))
347 }))
348 DispatchQueue.main.async {
349 UIImpactFeedbackGenerator(style: .light).impactOccurred()
350 }
351 return controller
352 }
353
354 let controller = MFMailComposeViewController()
355 controller.mailComposeDelegate = context.coordinator
356 controller.setToRecipients(draft.recipients)
357 if !draft.ccRecipients.isEmpty {
358 controller.setCcRecipients(draft.ccRecipients)
359 }
360 if !draft.subject.isEmpty {
361 controller.setSubject(draft.subject)
362 }
363 if !draft.body.isEmpty {
364 controller.setMessageBody(draft.body, isHTML: false)
365 }
366 return controller
367 }
368
369 func updateUIViewController(_ : UIViewController, context _: Context) {
370 // The view controller is fully configured in makeUIViewController.
371 // No state-driven updates are required.
372 }
373
374 final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
375 let onComplete: (Result) -> Void
376
377 init(onComplete: @escaping (Result) -> Void) {
378 self.onComplete = onComplete
379 }
380
381 func mailComposeController(
382 _ controller: MFMailComposeViewController,
383 didFinishWith result: MFMailComposeResult,
384 error: Error?
385 ) {
386 if error != nil {
387 let message = error?.localizedDescription ?? "The reply could not be sent."
388 presentFailureAlert(on: controller, message: message)
389 onComplete(.failed(message))
390 return
391 }
392 switch result {
393 case .cancelled:
394 controller.dismiss(animated: true)
395 onComplete(.cancelled)
396 case .saved:
397 controller.dismiss(animated: true)
398 onComplete(.saved)
399 case .sent:
400 controller.dismiss(animated: true)
401 onComplete(.sent)
402 case .failed:
403 let message = "Mail could not send the reply from the configured iOS Mail account."
404 presentFailureAlert(on: controller, message: message)
405 onComplete(.failed(message))
406 @unknown default:
407 let message = "Mail returned an unknown result while sending the reply."
408 presentFailureAlert(on: controller, message: message)
409 onComplete(.failed(message))
410 }
411 }
412
413 private func presentFailureAlert(on controller: UIViewController, message: String) {
414 guard controller.presentedViewController == nil else { return }
415 let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert)
416 alert.addAction(UIAlertAction(title: "OK", style: .default))
417 controller.present(alert, animated: true)
418 }
419 }
420}
421
422private final class MailUnavailableViewController: UIViewController {
423 private let onDismiss: () -> Void
424
425 init(onDismiss: @escaping () -> Void) {
426 self.onDismiss = onDismiss
427 super.init(nibName: nil, bundle: nil)
428 }
429
430 @available(*, unavailable)
431 required init?(coder _: NSCoder) {
432 fatalError("init(coder:) has not been implemented")
433 }
434
435 override func viewDidLoad() {
436 super.viewDidLoad()
437 view.backgroundColor = .systemBackground
438 navigationItem.title = "Reply"
439 navigationItem.rightBarButtonItem = UIBarButtonItem(
440 barButtonSystemItem: .done,
441 target: self,
442 action: #selector(dismissSelf)
443 )
444
445 let label = UILabel()
446 label.translatesAutoresizingMaskIntoConstraints = false
447 label.text = "Mail is not configured on this device."
448 label.textAlignment = .center
449 label.numberOfLines = 0
450 label.textColor = .secondaryLabel
451
452 view.addSubview(label)
453 NSLayoutConstraint.activate([
454 label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
455 label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
456 label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
457 ])
458 }
459
460 @objc
461 private func dismissSelf() {
462 dismiss(animated: true)
463 onDismiss()
464 }
465}