krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: 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
22 init(
23 thread: InboxThreadSummary,
24 onViewed: @escaping () -> Void,
25 onMarkRead: (() -> Void)? = nil,
26 onMarkUnread: (() -> Void)? = nil
27 ) {
28 self.thread = thread
29 self.onViewed = onViewed
30 self.onMarkRead = onMarkRead
31 self.onMarkUnread = onMarkUnread
32 self._isUnread = State(initialValue: thread.isUnread)
33 }
34
35 var body: some View {
36 Group {
37 if let viewModel {
38 content(viewModel)
39 } else {
40 SRHTLoadingStateView(message: "Loading thread…")
41 }
42 }
43 .navigationTitle("Thread")
44 .navigationBarTitleDisplayMode(.inline)
45 .task(id: thread.id) {
46 guard loadedThreadID != thread.id else { return }
47 let vm = ThreadViewModel(summary: thread, client: appState.client)
48 viewModel = vm
49 loadedThreadID = thread.id
50 hasMarkedCurrentThreadViewed = false
51 suppressAutoMarkViewed = false
52 isUnread = thread.isUnread
53 await vm.loadThread()
54 }
55 .onChange(of: viewModel?.thread?.id) { _, threadID in
56 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
57 hasMarkedCurrentThreadViewed = true
58 isUnread = false
59 onViewed()
60 }
61 .sheet(item: Binding(
62 get: { viewModel?.composeDraft },
63 set: { _ in viewModel?.dismissReply() }
64 )) { draft in
65 MailComposeView(draft: draft) { result in
66 switch result {
67 case .failed(let message):
68 inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)")
69 viewModel?.error = message
70 case .cancelled:
71 inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)")
72 case .saved:
73 inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)")
74 case .sent:
75 inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)")
76 replySuccessMessage = "Reply handed off to Mail."
77 Task {
78 await viewModel?.loadThread()
79 }
80 }
81 }
82 }
83 .overlay(alignment: .top) {
84 if let replySuccessMessage {
85 Text(replySuccessMessage)
86 .font(.caption.weight(.medium))
87 .padding(.horizontal, 12)
88 .padding(.vertical, 8)
89 .background(.thinMaterial, in: Capsule())
90 .padding(.top, 8)
91 .transition(.move(edge: .top).combined(with: .opacity))
92 }
93 }
94 .animation(.easeInOut(duration: 0.2), value: replySuccessMessage)
95 .onChange(of: replySuccessMessage) { _, message in
96 guard message != nil else { return }
97 Task { @MainActor in
98 try? await Task.sleep(for: .seconds(2))
99 if self.replySuccessMessage == message {
100 self.replySuccessMessage = nil
101 }
102 }
103 }
104 }
105
106 @ViewBuilder
107 private func content(_ viewModel: ThreadViewModel) -> some View {
108 @Bindable var vm = viewModel
109
110 List {
111 if let thread = viewModel.thread {
112 Section {
113 VStack(alignment: .leading, spacing: 6) {
114 Text(thread.displaySubject)
115 .font(.headline)
116 Text(headerMetadata(thread))
117 .font(.caption)
118 .foregroundStyle(.secondary)
119 }
120 .padding(.vertical, 4)
121 }
122
123 if let partialWarning = viewModel.partialWarning {
124 Section {
125 Text(partialWarning)
126 .font(.caption)
127 .foregroundStyle(.secondary)
128 }
129 }
130
131 ForEach(thread.messages) { message in
132 InboxMessageRow(message: message)
133 }
134 }
135 }
136 .listStyle(.plain)
137 .toolbar {
138 ToolbarItem(placement: .topBarTrailing) {
139 HStack {
140 if onMarkRead != nil || onMarkUnread != nil {
141 Button(isUnread ? "Mark Read" : "Mark Unread") {
142 suppressAutoMarkViewed = !isUnread
143 if isUnread {
144 onMarkRead?()
145 isUnread = false
146 } else {
147 onMarkUnread?()
148 isUnread = true
149 }
150 }
151 }
152
153 Button("Reply") {
154 viewModel.prepareReply()
155 }
156 }
157 }
158 }
159 .overlay {
160 if viewModel.isLoading, viewModel.thread == nil {
161 SRHTLoadingStateView(message: "Loading thread…")
162 } else if let error = viewModel.error, viewModel.thread == nil {
163 SRHTErrorStateView(
164 title: "Failed to load thread",
165 message: error,
166 retryAction: { await viewModel.loadThread() }
167 )
168 }
169 }
170 .srhtErrorBanner(error: $vm.error)
171 .refreshable {
172 await viewModel.loadThread()
173 }
174 }
175
176 private func headerMetadata(_ thread: InboxThreadDetail) -> String {
177 var parts = [thread.listDisplayName]
178 if let messageCount = thread.messageCount, messageCount > 1 {
179 parts.append("\(messageCount) messages")
180 }
181 parts.append(thread.lastActivityAt.relativeDescription)
182 return parts.joined(separator: " • ")
183 }
184}
185
186private struct InboxMessageRow: View {
187 let message: InboxMessage
188
189 var body: some View {
190 VStack(alignment: .leading, spacing: 10) {
191 HStack(alignment: .top, spacing: 12) {
192 VStack(alignment: .leading, spacing: 2) {
193 Text(senderLine)
194 .font(.subheadline.weight(.medium))
195 .lineLimit(2)
196 Text(message.date.formatted(date: .abbreviated, time: .shortened))
197 .font(.caption)
198 .foregroundStyle(.secondary)
199 }
200
201 Spacer()
202
203 if message.isPatch {
204 Text("Patch")
205 .font(.caption2.weight(.medium))
206 .foregroundStyle(.secondary)
207 }
208 }
209
210 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
211 switch block {
212 case .plainText(let text):
213 Text(text)
214 .font(.body)
215 .textSelection(.enabled)
216 .frame(maxWidth: .infinity, alignment: .leading)
217 .fixedSize(horizontal: false, vertical: true)
218 case .diff(let diff):
219 ScrollView(.horizontal) {
220 DiffView(diff: diff)
221 .textSelection(.enabled)
222 .frame(maxWidth: .infinity, alignment: .leading)
223 }
224 }
225 }
226 }
227 .padding(.vertical, 6)
228 .listRowSeparator(.visible)
229 }
230
231 private var senderLine: String {
232 if let email = message.senderEmailAddress,
233 email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame {
234 return "\(message.senderDisplayName) <\(email)>"
235 }
236 return message.senderDisplayName
237 }
238}
239
240private struct MailComposeView: UIViewControllerRepresentable {
241 let draft: MailComposeDraft
242 let onComplete: (Result) -> Void
243
244 enum Result {
245 case cancelled
246 case saved
247 case sent
248 case failed(String)
249 }
250
251 func makeCoordinator() -> Coordinator {
252 Coordinator(onComplete: onComplete)
253 }
254
255 func makeUIViewController(context: Context) -> UIViewController {
256 guard MFMailComposeViewController.canSendMail() else {
257 let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: {
258 context.coordinator.onComplete(.failed("Mail is not configured on this device."))
259 }))
260 DispatchQueue.main.async {
261 UIImpactFeedbackGenerator(style: .light).impactOccurred()
262 }
263 return controller
264 }
265
266 let controller = MFMailComposeViewController()
267 controller.mailComposeDelegate = context.coordinator
268 controller.setToRecipients(draft.recipients)
269 if !draft.ccRecipients.isEmpty {
270 controller.setCcRecipients(draft.ccRecipients)
271 }
272 if !draft.subject.isEmpty {
273 controller.setSubject(draft.subject)
274 }
275 if !draft.body.isEmpty {
276 controller.setMessageBody(draft.body, isHTML: false)
277 }
278 return controller
279 }
280
281 func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
282
283 final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
284 let onComplete: (Result) -> Void
285
286 init(onComplete: @escaping (Result) -> Void) {
287 self.onComplete = onComplete
288 }
289
290 func mailComposeController(
291 _ controller: MFMailComposeViewController,
292 didFinishWith result: MFMailComposeResult,
293 error: Error?
294 ) {
295 if error != nil {
296 let message = error?.localizedDescription ?? "The reply could not be sent."
297 presentFailureAlert(on: controller, message: message)
298 onComplete(.failed(message))
299 return
300 }
301 switch result {
302 case .cancelled:
303 controller.dismiss(animated: true)
304 onComplete(.cancelled)
305 case .saved:
306 controller.dismiss(animated: true)
307 onComplete(.saved)
308 case .sent:
309 controller.dismiss(animated: true)
310 onComplete(.sent)
311 case .failed:
312 let message = "Mail could not send the reply from the configured iOS Mail account."
313 presentFailureAlert(on: controller, message: message)
314 onComplete(.failed(message))
315 @unknown default:
316 let message = "Mail returned an unknown result while sending the reply."
317 presentFailureAlert(on: controller, message: message)
318 onComplete(.failed(message))
319 }
320 }
321
322 private func presentFailureAlert(on controller: UIViewController, message: String) {
323 guard controller.presentedViewController == nil else { return }
324 let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert)
325 alert.addAction(UIAlertAction(title: "OK", style: .default))
326 controller.present(alert, animated: true)
327 }
328 }
329}
330
331private final class MailUnavailableViewController: UIViewController {
332 private let onDismiss: () -> Void
333
334 init(onDismiss: @escaping () -> Void) {
335 self.onDismiss = onDismiss
336 super.init(nibName: nil, bundle: nil)
337 }
338
339 @available(*, unavailable)
340 required init?(coder: NSCoder) {
341 fatalError("init(coder:) has not been implemented")
342 }
343
344 override func viewDidLoad() {
345 super.viewDidLoad()
346 view.backgroundColor = .systemBackground
347 navigationItem.title = "Reply"
348 navigationItem.rightBarButtonItem = UIBarButtonItem(
349 barButtonSystemItem: .done,
350 target: self,
351 action: #selector(dismissSelf)
352 )
353
354 let label = UILabel()
355 label.translatesAutoresizingMaskIntoConstraints = false
356 label.text = "Mail is not configured on this device."
357 label.textAlignment = .center
358 label.numberOfLines = 0
359 label.textColor = .secondaryLabel
360
361 view.addSubview(label)
362 NSLayoutConstraint.activate([
363 label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
364 label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
365 label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
366 ])
367 }
368
369 @objc
370 private func dismissSelf() {
371 dismiss(animated: true)
372 onDismiss()
373 }
374}