krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.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 @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 }
127
128 Section("Related") {
129 Button {
130 appState.navigateToMailingList(
131 InboxMailingListReference(
132 id: thread.listID,
133 rid: thread.listRID,
134 name: thread.listName,
135 owner: thread.listOwner
136 )
137 )
138 } label: {
139 Label(thread.listDisplayName, systemImage: "list.bullet")
140 }
141
142 if let repo = self.thread.repo {
143 Button {
144 openRepository(named: repo, ownerCanonicalName: thread.listOwner.canonicalName)
145 } label: {
146 HStack {
147 Label("\(thread.listOwner.canonicalName)/\(repo)", systemImage: "book.closed")
148 Spacer()
149 if isOpeningRepository {
150 ProgressView()
151 .controlSize(.small)
152 } else {
153 Image(systemName: "arrow.up.right")
154 .font(.caption)
155 .foregroundStyle(.tertiary)
156 }
157 }
158 }
159 .disabled(isOpeningRepository)
160 }
161 }
162
163 if let partialWarning = viewModel.partialWarning {
164 Section {
165 Text(partialWarning)
166 .font(.caption)
167 .foregroundStyle(.secondary)
168 }
169 }
170
171 ForEach(thread.messages) { message in
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 )
186 }
187 }
188 }
189 .listStyle(.plain)
190 .toolbar {
191 ToolbarItem(placement: .topBarTrailing) {
192 HStack {
193 if onMarkRead != nil || onMarkUnread != nil {
194 Button(isUnread ? "Mark Read" : "Mark Unread") {
195 suppressAutoMarkViewed = !isUnread
196 if isUnread {
197 onMarkRead?()
198 isUnread = false
199 } else {
200 onMarkUnread?()
201 isUnread = true
202 }
203 }
204 }
205
206 Button("Reply") {
207 viewModel.prepareReply()
208 }
209 }
210 }
211 }
212 .overlay {
213 if viewModel.isLoading, viewModel.thread == nil {
214 SRHTLoadingStateView(message: "Loading thread…")
215 } else if let error = viewModel.error, viewModel.thread == nil {
216 SRHTErrorStateView(
217 title: "Failed to load thread",
218 message: error,
219 retryAction: { await viewModel.loadThread() }
220 )
221 }
222 }
223 .srhtErrorBanner(error: $vm.error)
224 .refreshable {
225 await viewModel.loadThread()
226 }
227 }
228
229 private func headerMetadata(_ thread: InboxThreadDetail) -> String {
230 var parts = [thread.listDisplayName]
231 if let messageCount = thread.messageCount, messageCount > 1 {
232 parts.append("\(messageCount) messages")
233 }
234 parts.append(thread.lastActivityAt.relativeDescription)
235 return parts.joined(separator: " • ")
236 }
237
238 private func openRepository(named repositoryName: String, ownerCanonicalName: String) {
239 guard !isOpeningRepository else { return }
240 isOpeningRepository = true
241 Task {
242 defer { isOpeningRepository = false }
243 do {
244 let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
245 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
246 appState.navigateToRepository(repository)
247 } catch {
248 appState.presentRepositoryDeepLinkError()
249 }
250 }
251 }
252}
253
254private struct InboxMessageRow: View {
255 let message: InboxMessage
256 var isCollapsed: Bool = false
257 var canCollapse: Bool = false
258 var onToggleCollapse: (() -> Void)? = nil
259
260 var body: some View {
261 VStack(alignment: .leading, spacing: isCollapsed ? 0 : 10) {
262 Button {
263 onToggleCollapse?()
264 } label: {
265 HStack(alignment: .top, spacing: 12) {
266 if canCollapse {
267 Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
268 .font(.caption)
269 .foregroundStyle(.tertiary)
270 .padding(.top, 4)
271 }
272
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()
283
284 if message.isPatch {
285 Text("Patch")
286 .font(.caption2.weight(.medium))
287 .foregroundStyle(.secondary)
288 }
289 }
290 }
291 .buttonStyle(.plain)
292
293 if !isCollapsed {
294 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
295 switch block {
296 case .plainText(let text):
297 Text(text)
298 .font(.body)
299 .textSelection(.enabled)
300 .frame(maxWidth: .infinity, alignment: .leading)
301 .fixedSize(horizontal: false, vertical: true)
302 case .diff(let diff):
303 DiffView(diff: diff)
304 .textSelection(.enabled)
305 }
306 }
307 }
308 }
309 .padding(.vertical, isCollapsed ? 4 : 6)
310 .listRowSeparator(.visible)
311 }
312
313 private var senderLine: String {
314 if let email = message.senderEmailAddress,
315 email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame {
316 return "\(message.senderDisplayName) <\(email)>"
317 }
318 return message.senderDisplayName
319 }
320}
321
322private struct MailComposeView: UIViewControllerRepresentable {
323 let draft: MailComposeDraft
324 let onComplete: (Result) -> Void
325
326 enum Result {
327 case cancelled
328 case saved
329 case sent
330 case failed(String)
331 }
332
333 func makeCoordinator() -> Coordinator {
334 Coordinator(onComplete: onComplete)
335 }
336
337 func makeUIViewController(context: Context) -> UIViewController {
338 guard MFMailComposeViewController.canSendMail() else {
339 let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: {
340 context.coordinator.onComplete(.failed("Mail is not configured on this device."))
341 }))
342 DispatchQueue.main.async {
343 UIImpactFeedbackGenerator(style: .light).impactOccurred()
344 }
345 return controller
346 }
347
348 let controller = MFMailComposeViewController()
349 controller.mailComposeDelegate = context.coordinator
350 controller.setToRecipients(draft.recipients)
351 if !draft.ccRecipients.isEmpty {
352 controller.setCcRecipients(draft.ccRecipients)
353 }
354 if !draft.subject.isEmpty {
355 controller.setSubject(draft.subject)
356 }
357 if !draft.body.isEmpty {
358 controller.setMessageBody(draft.body, isHTML: false)
359 }
360 return controller
361 }
362
363 func updateUIViewController(_ : UIViewController, context _: Context) {
364 // The view controller is fully configured in makeUIViewController.
365 // No state-driven updates are required.
366 }
367
368 final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
369 let onComplete: (Result) -> Void
370
371 init(onComplete: @escaping (Result) -> Void) {
372 self.onComplete = onComplete
373 }
374
375 func mailComposeController(
376 _ controller: MFMailComposeViewController,
377 didFinishWith result: MFMailComposeResult,
378 error: Error?
379 ) {
380 if error != nil {
381 let message = error?.localizedDescription ?? "The reply could not be sent."
382 presentFailureAlert(on: controller, message: message)
383 onComplete(.failed(message))
384 return
385 }
386 switch result {
387 case .cancelled:
388 controller.dismiss(animated: true)
389 onComplete(.cancelled)
390 case .saved:
391 controller.dismiss(animated: true)
392 onComplete(.saved)
393 case .sent:
394 controller.dismiss(animated: true)
395 onComplete(.sent)
396 case .failed:
397 let message = "Mail could not send the reply from the configured iOS Mail account."
398 presentFailureAlert(on: controller, message: message)
399 onComplete(.failed(message))
400 @unknown default:
401 let message = "Mail returned an unknown result while sending the reply."
402 presentFailureAlert(on: controller, message: message)
403 onComplete(.failed(message))
404 }
405 }
406
407 private func presentFailureAlert(on controller: UIViewController, message: String) {
408 guard controller.presentedViewController == nil else { return }
409 let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert)
410 alert.addAction(UIAlertAction(title: "OK", style: .default))
411 controller.present(alert, animated: true)
412 }
413 }
414}
415
416private final class MailUnavailableViewController: UIViewController {
417 private let onDismiss: () -> Void
418
419 init(onDismiss: @escaping () -> Void) {
420 self.onDismiss = onDismiss
421 super.init(nibName: nil, bundle: nil)
422 }
423
424 @available(*, unavailable)
425 required init?(coder _: NSCoder) {
426 fatalError("init(coder:) has not been implemented")
427 }
428
429 override func viewDidLoad() {
430 super.viewDidLoad()
431 view.backgroundColor = .systemBackground
432 navigationItem.title = "Reply"
433 navigationItem.rightBarButtonItem = UIBarButtonItem(
434 barButtonSystemItem: .done,
435 target: self,
436 action: #selector(dismissSelf)
437 )
438
439 let label = UILabel()
440 label.translatesAutoresizingMaskIntoConstraints = false
441 label.text = "Mail is not configured on this device."
442 label.textAlignment = .center
443 label.numberOfLines = 0
444 label.textColor = .secondaryLabel
445
446 view.addSubview(label)
447 NSLayoutConstraint.activate([
448 label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
449 label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
450 label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
451 ])
452 }
453
454 @objc
455 private func dismissSelf() {
456 dismiss(animated: true)
457 onDismiss()
458 }
459}