diff --git a/Tusker/Controllers/MastodonController.swift b/Tusker/Controllers/MastodonController.swift index 362e430b..f7bf6dc3 100644 --- a/Tusker/Controllers/MastodonController.swift +++ b/Tusker/Controllers/MastodonController.swift @@ -46,8 +46,8 @@ class MastodonController: ObservableObject { @Published private(set) var instance: Instance! private(set) var customEmojis: [Emoji]? - private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]() - private var ownInstanceRequest: URLSessionTask? + @MainActor private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]() + @MainActor private var ownInstanceRequest: URLSessionTask? var loggedIn: Bool { accountInfo != nil @@ -118,14 +118,13 @@ class MastodonController: ObservableObject { } } + @MainActor func getOwnInstance(completion: ((Instance) -> Void)? = nil) { getOwnInstanceInternal(retryAttempt: 0, completion: completion) } + @MainActor private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Instance) -> Void)?) { - // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks - assert(Thread.isMainThread) - if let instance = self.instance { completion?(instance) } else { diff --git a/Tusker/Models/CompositionAttachmentData.swift b/Tusker/Models/CompositionAttachmentData.swift index ab11fd68..01f1f6e1 100644 --- a/Tusker/Models/CompositionAttachmentData.swift +++ b/Tusker/Models/CompositionAttachmentData.swift @@ -104,6 +104,14 @@ enum CompositionAttachmentData { } } + func getData() async -> (Data, String) { + return await withCheckedContinuation { continuation in + getData { data, mimeType in + continuation.resume(returning: (data, mimeType)) + } + } + } + private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Data, String) -> Void) { session.outputFileType = .mp4 session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index ab5d8a90..b8cedf92 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -19,7 +19,7 @@ struct ComposeView: View { @State private var postProgress: Double = 0 @State private var postTotalProgress: Double = 0 @State private var isShowingPostErrorAlert = false - @State private var postError: PostError? + @State private var postError: Error? private let stackPadding: CGFloat = 8 @@ -148,7 +148,11 @@ struct ComposeView: View { } private var postButton: some View { - Button(action: self.postStatus) { + Button { + async { + await self.postStatus() + } + } label: { Text("Post") } .disabled(!postButtonEnabled) @@ -190,7 +194,7 @@ struct ComposeView: View { ]) } - private func postStatus() { + private func postStatus() async { guard draft.hasContent else { return } isPosting = true @@ -205,71 +209,58 @@ struct ComposeView: View { 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 + let uploadedAttachments: [Attachment] + do { + uploadedAttachments = try await uploadAttachments() + } catch { + self.isShowingPostErrorAlert = true + self.postError = error + self.postProgress = 0 + self.postTotalProgress = 0 + self.isPosting = false + return + } + + let request = Client.createStatus(text: draft.textForPosting, + 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) + do { + try await mastodonController.run(request) + self.postProgress += 1 - case let .success(uploadedAttachments): - let request = Client.createStatus(text: draft.textForPosting, - 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) - 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) - } - } - } + 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) } + } catch { + self.isShowingPostErrorAlert = true + self.postError = error } } - private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) { - let group = DispatchGroup() - - var attachmentDatas = [(Data, String)?]() - - for (index, compAttachment) in draft.attachments.enumerated() { - group.enter() - - attachmentDatas.append(nil) - - compAttachment.data.getData { (data, mimeType) in - postProgress += 1 - - attachmentDatas[index] = (data, mimeType) - group.leave() - } + private func uploadAttachments() async throws -> [Attachment] { + guard !draft.attachments.isEmpty else { + return [] } - group.notify(queue: .global(qos: .userInitiated)) { - - var anyFailed = false - var uploadedAttachments = [Result?]() - + return try await withThrowingTaskGroup(of: (CompositionAttachment, (Data, String)).self) { taskGroup in + for compAttachment in draft.attachments { + postProgress += 1 + taskGroup.async { + return (compAttachment, await compAttachment.data.getData()) + } + } + // 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, @@ -277,67 +268,40 @@ struct ComposeView: View { // posted status reflects order the user set. // Pleroma does respect the order of the `media_ids` parameter. - for (index, (data, mimeType)) in attachmentDatas.map(\.unsafelyUnwrapped).enumerated() { - group.enter() - - let compAttachment = draft.attachments[index] + var anyFailed = false + var uploaded = [Result]() + + for try await (compAttachment, (data, mimeType)) in taskGroup { 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() + + do { + let (uploadedAttachment, _) = try await mastodonController.run(request) + uploaded.append(.success(uploadedAttachment)) + postProgress += 1 + } catch { + uploaded.append(.failure(error)) + anyFailed = true } - - group.wait() } - - + if anyFailed { - let errors = uploadedAttachments.map { (result) -> Error? in + let errors = uploaded.map { result -> Error? in if case let .failure(error) = result { return error } else { return nil } } - completion(.failure(AttachmentUploadError(errors: errors))) + throw AttachmentUploadError(errors: errors) } else { - let uploadedAttachments = uploadedAttachments.map { - try! $0!.get() - } - completion(.success(uploadedAttachments)) + return uploaded.map { try! $0.get() } } - } } } -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 { +fileprivate struct AttachmentUploadError: LocalizedError { let errors: [Error?] var localizedDescription: String {