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}