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}