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}