krz/hutch

an ios client for sourcehut

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

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