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