From 2f7c7bae5ebbf2d216ad9430259401c0f8ce96bb Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 30 Apr 2022 11:11:22 -0400 Subject: [PATCH] Extract status posting to separate class, convert to async/await --- Tusker.xcodeproj/project.pbxproj | 12 ++ Tusker/Screens/Compose/ComposeView.swift | 236 ++++++----------------- Tusker/Services/PostService.swift | 122 ++++++++++++ Tusker/Views/WrappedProgressView.swift | 8 +- 4 files changed, 202 insertions(+), 176 deletions(-) create mode 100644 Tusker/Services/PostService.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index ebe299ba..29c7f55f 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -287,6 +287,7 @@ D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; }; D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; }; D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D6E57FA525C26FAB00341037 /* Localizable.stringsdict */; }; + D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; }; D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; @@ -635,6 +636,7 @@ D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = ""; }; D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = ""; }; D6E57FA425C26FAB00341037 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; + D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = ""; }; D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = ""; }; @@ -1366,6 +1368,7 @@ D61959D2241E846D00A37B8E /* Models */, D663626021360A9600C9CBA2 /* Preferences */, D641C780213DD7C4004B4513 /* Screens */, + D6E9CDA6281A426700BBC98E /* Services */, D62D241E217AA46B005076CC /* Shortcuts */, D67B506B250B28FF00FAECFB /* Vendor */, D6BED1722126661300F02DA0 /* Views */, @@ -1422,6 +1425,14 @@ path = OpenInTusker; sourceTree = ""; }; + D6E9CDA6281A426700BBC98E /* Services */ = { + isa = PBXGroup; + children = ( + D6E9CDA7281A427800BBC98E /* PostService.swift */, + ); + path = Services; + sourceTree = ""; + }; D6F1F84E2193B9BE00F5FE67 /* Caching */ = { isa = PBXGroup; children = ( @@ -1827,6 +1838,7 @@ D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */, D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, + D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index cc8b04b8..97cb61de 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -10,16 +10,49 @@ import SwiftUI import Pachyderm import Combine +@propertyWrapper struct OptionalStateObject: DynamicProperty { + private class Republisher: ObservableObject { + var cancellable: AnyCancellable? + var wrapped: T? { + didSet { + cancellable?.cancel() + cancellable = wrapped?.objectWillChange + .receive(on: RunLoop.main) + .sink { [unowned self] _ in + self.objectWillChange.send() + } + } + } + } + + @StateObject private var republisher = Republisher() + @State private var object: T? + var wrappedValue: T? { + get { + object + } + nonmutating set { + object = newValue + } + } + + func update() { + republisher.wrapped = wrappedValue + } +} + struct ComposeView: View { @ObservedObject var draft: Draft @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState - @State private var isPosting = false - @State private var postProgress: Double = 0 - @State private var postTotalProgress: Double = 0 + @OptionalStateObject private var poster: PostService? @State private var isShowingPostErrorAlert = false - @State private var postError: PostError? + @State private var postError: PostService.Error? + + private var isPosting: Bool { + poster != nil + } private let stackPadding: CGFloat = 8 @@ -58,9 +91,9 @@ struct ComposeView: View { } } - if postProgress > 0 { + if let poster = poster { // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 - WrappedProgressView(value: postProgress, total: postTotalProgress) + WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) } autocompleteSuggestions @@ -123,6 +156,7 @@ struct ComposeView: View { // the list rows provide their own padding, so we cancel out the extra spacing from the VStack .padding([.top, .bottom], -8) } + .disabled(isPosting) .padding(stackPadding) .padding(.bottom, uiState.autocompleteState != nil ? 46 : nil) } @@ -147,7 +181,11 @@ struct ComposeView: View { } private var postButton: some View { - Button(action: self.postStatus) { + Button { + Task { + await self.postStatus() + } + } label: { Text("Post") } .disabled(!postButtonEnabled) @@ -184,179 +222,31 @@ struct ComposeView: View { ]) } - private func postStatus() { - guard draft.hasContent else { return } - - isPosting = true - - // save before posting, so if a crash occurs during network request, the status won't be lost - DraftsManager.save() - - let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil - let sensitive = contentWarning != nil - - // 2 steps (request data, then upload) for each attachment - postTotalProgress = Double(2 + (draft.attachments.count * 2)) - postProgress = 1 - - uploadAttachments { (result) in - switch result { - case let .failure(error): - self.isShowingPostErrorAlert = true - self.postError = error - self.postProgress = 0 - self.postTotalProgress = 0 - self.isPosting = false - - case let .success(uploadedAttachments): - let request = Client.createStatus(text: draft.textForPosting(on: mastodonController.instanceFeatures), - contentType: Preferences.shared.statusContentType, - inReplyTo: draft.inReplyToID, - media: uploadedAttachments, - sensitive: sensitive, - spoilerText: contentWarning, - visibility: draft.visibility, - language: nil, - pollOptions: draft.poll?.options.map(\.text), - pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), - pollMultiple: draft.poll?.multiple, - localOnly: mastodonController.instanceFeatures.instanceType == .hometown ? draft.localOnly : nil) - self.mastodonController.run(request) { (response) in - switch response { - case let .failure(error): - self.isShowingPostErrorAlert = true - self.postError = error - - case .success(_, _): - self.postProgress += 1 - - DraftsManager.shared.remove(self.draft) - - // wait .25 seconds so the user can see the progress bar has completed - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { - self.uiState.delegate?.dismissCompose(mode: .post) - } - } - } - } - } - } - - private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) { - let group = DispatchGroup() - - var attachmentDataResults = [Result<(Data, String), CompositionAttachmentData.Error>?]() - - for (index, compAttachment) in draft.attachments.enumerated() { - group.enter() - - attachmentDataResults.append(nil) - - compAttachment.data.getData { (result) in - postProgress += 1 - - attachmentDataResults[index] = result - group.leave() - } + private func postStatus() async { + guard !isPosting, + draft.hasContent else { + return } - group.notify(queue: .global(qos: .userInitiated)) { - - var anyFailed = false - var uploadedAttachments = [Result?]() + let poster = PostService(mastodonController: mastodonController, draft: draft) + self.poster = poster - // Mastodon does not respect the order of the `media_ids` parameter in the create post request, - // it determines attachment order by which was uploaded first. Since the upload attachment request - // does not include any timestamp data, and requests may arrive at the server out-of-order, - // attachments need to be uploaded serially in order to ensure the order of attachments in the - // posted status reflects order the user set. - // Pleroma does respect the order of the `media_ids` parameter. + do { + try await poster.post() - let datas: [(Data, String)] - do { - datas = try attachmentDataResults.map { try $0!.get() } - } catch { - completion(.failure(AttachmentUploadError(errors: [error]))) - return - } + // wait .25 seconds so the user can see the progress bar has completed + try? await Task.sleep(nanoseconds: 250_000_000) - for (index, (data, mimeType)) in datas.enumerated() { - group.enter() - - let compAttachment = draft.attachments[index] - let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") - let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription) - self.mastodonController.run(request) { (response) in - switch response { - case let .failure(error): - uploadedAttachments.append(.failure(error)) - anyFailed = true - - case let .success(attachment, _): - self.postProgress += 1 - uploadedAttachments.append(.success(attachment)) - } - - group.leave() - } - - group.wait() - } + uiState.delegate?.dismissCompose(mode: .post) - - if anyFailed { - let errors = uploadedAttachments.map { (result) -> Error? in - if case let .failure(error) = result { - return error - } else { - return nil - } - } - completion(.failure(AttachmentUploadError(errors: errors))) - } else { - let uploadedAttachments = uploadedAttachments.map { - try! $0!.get() - } - completion(.success(uploadedAttachments)) - } - + } catch let error as PostService.Error { + self.isShowingPostErrorAlert = true + self.postError = error + } catch { + fatalError("Unreachable") } - } -} - -fileprivate protocol PostError: LocalizedError {} - -extension PostError { - var localizedDescription: String { - if let self = self as? Client.Error { - return self.localizedDescription - } else if let self = self as? AttachmentUploadError { - return self.localizedDescription - } else { - return "Unknown Error" - } - } -} - -extension Client.Error: PostError {} - -fileprivate struct AttachmentUploadError: PostError { - let errors: [Error?] - - var localizedDescription: String { - return errors.enumerated().compactMap { (index, error) -> String? in - guard let error = error else { return nil } - let description: String - // need to downcast to use more specific localizedDescription impl from Pachyderm - if let error = error as? Client.Error { - description = error.localizedDescription - } else if let error = error as? CompositionAttachmentData.Error { - description = error.localizedDescription - } else { - description = error.localizedDescription - } - return "Attachment \(index + 1): \(description)" - }.joined(separator: ",\n") + + self.poster = nil } } diff --git a/Tusker/Services/PostService.swift b/Tusker/Services/PostService.swift new file mode 100644 index 00000000..1050394d --- /dev/null +++ b/Tusker/Services/PostService.swift @@ -0,0 +1,122 @@ +// +// PostService.swift +// Tusker +// +// Created by Shadowfacts on 4/27/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm + +class PostService: ObservableObject { + private let mastodonController: MastodonController + private let draft: Draft + let totalSteps: Int + + @Published var currentStep = 1 + + init(mastodonController: MastodonController, draft: Draft) { + self.mastodonController = mastodonController + self.draft = draft + // 2 steps (request data, then upload) for each attachment + self.totalSteps = 2 + (draft.attachments.count * 2) + } + + @MainActor + func post() async throws { + guard draft.hasContent else { + return + } + + // save before posting, so if a crash occurs during network request, the status won't be lost + DraftsManager.save() + + let uploadedAttachments = try await uploadAttachments() + + let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil + let sensitive = contentWarning != nil + + let request = Client.createStatus( + text: draft.textForPosting(on: mastodonController.instanceFeatures), + contentType: Preferences.shared.statusContentType, + inReplyTo: draft.inReplyToID, + media: uploadedAttachments, + sensitive: sensitive, + spoilerText: contentWarning, + visibility: draft.visibility, + language: nil, + pollOptions: draft.poll?.options.map(\.text), + pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), + pollMultiple: draft.poll?.multiple, + localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil + ) + do { + let (_, _) = try await mastodonController.run(request) + currentStep += 1 + + DraftsManager.shared.remove(self.draft) + } catch let error as Client.Error { + throw Error.posting(error) + } + } + + private func uploadAttachments() async throws -> [Attachment] { + var attachments: [Attachment] = [] + attachments.reserveCapacity(draft.attachments.count) + for (index, attachment) in draft.attachments.enumerated() { + let data: Data + let mimeType: String + do { + (data, mimeType) = try await getData(for: attachment) + currentStep += 1 + } catch let error as CompositionAttachmentData.Error { + throw Error.attachmentData(index: index, cause: error) + } + do { + let uploaded = try await uploadAttachment(data: data, mimeType: mimeType, description: attachment.description) + attachments.append(uploaded) + currentStep += 1 + } catch let error as Client.Error { + throw Error.attachmentUpload(index: index, cause: error) + } + } + return attachments + } + + private func getData(for attachment: CompositionAttachment) async throws -> (Data, String) { + return try await withCheckedThrowingContinuation { continuation in + attachment.data.getData { result in + switch result { + case let .success(res): + continuation.resume(returning: res) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + + private func uploadAttachment(data: Data, mimeType: String, description: String?) async throws -> Attachment { + let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") + let req = Client.upload(attachment: formAttachment, description: description) + return try await mastodonController.run(req).0 + } + + enum Error: Swift.Error, LocalizedError { + case attachmentData(index: Int, cause: CompositionAttachmentData.Error) + case attachmentUpload(index: Int, cause: Client.Error) + case posting(Client.Error) + + var localizedDescription: String { + switch self { + case let .attachmentData(index: index, cause: cause): + return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .attachmentUpload(index: index, cause: cause): + return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .posting(error): + return error.localizedDescription + } + } + } +} diff --git a/Tusker/Views/WrappedProgressView.swift b/Tusker/Views/WrappedProgressView.swift index 933328e7..aa32a433 100644 --- a/Tusker/Views/WrappedProgressView.swift +++ b/Tusker/Views/WrappedProgressView.swift @@ -11,8 +11,8 @@ import SwiftUI struct WrappedProgressView: UIViewRepresentable { typealias UIViewType = UIProgressView - let value: Double - let total: Double + let value: Int + let total: Int func makeUIView(context: Context) -> UIProgressView { return UIProgressView(progressViewStyle: .bar) @@ -20,7 +20,9 @@ struct WrappedProgressView: UIViewRepresentable { func updateUIView(_ uiView: UIProgressView, context: Context) { if total > 0 { - uiView.setProgress(Float(value / total), animated: true) + let progress = Float(value) / Float(total) + print(progress) + uiView.setProgress(progress, animated: true) } else { uiView.setProgress(0, animated: true) }