krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.9.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}