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