// // PostService.swift // ComposeUI // // Created by Shadowfacts on 4/27/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import Foundation import Pachyderm import UniformTypeIdentifiers @MainActor class PostService: ObservableObject { private let mastodonController: ComposeMastodonContext private let config: ComposeUIConfig private let draft: Draft @Published var currentStep = 1 @Published private(set) var totalSteps = 2 init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) { self.mastodonController = mastodonController self.config = config self.draft = draft } func post() async throws { guard draft.hasContent || draft.editedStatusID != nil else { return } // save before posting, so if a crash occurs during network request, the status won't be lost DraftsPersistentContainer.shared.save() let uploadedAttachments = try await uploadAttachments() let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : "" let sensitive = !contentWarning.isEmpty let request: Request if let editedStatusID = draft.editedStatusID { if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest { await updateEditedAttachments() } request = Client.editStatus( id: editedStatusID, text: textForPosting(), contentType: config.contentType, spoilerText: contentWarning, sensitive: sensitive, language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil, mediaIDs: uploadedAttachments, mediaAttributes: draft.draftAttachments.compactMap { if let id = $0.editedAttachmentID { return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil) } else { return nil } }, poll: draft.poll.map { EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple) } ) } else { request = Client.createStatus( text: textForPosting(), contentType: config.contentType, inReplyTo: draft.inReplyToID, mediaIDs: uploadedAttachments, sensitive: sensitive, spoilerText: contentWarning, visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue, language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil, pollOptions: draft.poll?.pollOptions.map(\.text), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), pollMultiple: draft.poll?.multiple, localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil, idempotencyKey: draft.id.uuidString ) } do { let (status, _) = try await mastodonController.run(request) currentStep += 1 mastodonController.storeCreatedStatus(status) } catch let error as Client.Error { throw Error.posting(error) } } private func uploadAttachments() async throws -> [String] { // 2 steps (request data, then upload) for each attachment self.totalSteps += 2 * draft.attachments.count var attachments: [String] = [] attachments.reserveCapacity(draft.attachments.count) for (index, attachment) in draft.draftAttachments.enumerated() { // if this attachment already exists and is being edited, we don't do anything // edits to the description are handled as part of the edit status request if let editedAttachmentID = attachment.editedAttachmentID { attachments.append(editedAttachmentID) currentStep += 2 continue } let data: Data let utType: UTType do { (data, utType) = try await getData(for: attachment) currentStep += 1 } catch let error as DraftAttachment.ExportError { throw Error.attachmentData(index: index, cause: error) } let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription) attachments.append(uploaded.id) currentStep += 1 } return attachments } private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) { return try await withCheckedThrowingContinuation { continuation in attachment.getData(features: mastodonController.instanceFeatures) { result in switch result { case let .success(res): continuation.resume(returning: res) case let .failure(error): continuation.resume(throwing: error) } } } } private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment { guard let mimeType = utType.preferredMIMEType else { throw Error.attachmentMissingMimeType(index: index, type: utType) } var filename = "file" if let ext = utType.preferredFilenameExtension { filename.append(".\(ext)") } let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename) let req = Client.upload(attachment: formAttachment, description: description) do { return try await mastodonController.run(req).0 } catch let error as Client.Error { throw Error.attachmentUpload(index: index, cause: error) } } private func textForPosting() -> String { var text = draft.text // when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text, // which we want to strip out before actually posting the status text = text.replacingOccurrences(of: "\u{fffc}", with: "") if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack { text += " 👁" } return text } // only needed for akkoma, not used on regular mastodon private func updateEditedAttachments() async { for attachment in draft.draftAttachments { guard let id = attachment.editedAttachmentID else { continue } let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil) _ = try? await mastodonController.run(req) } } enum Error: Swift.Error, LocalizedError { case attachmentData(index: Int, cause: DraftAttachment.ExportError) case attachmentMissingMimeType(index: Int, type: UTType) 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 .attachmentMissingMimeType(index: index, type: type): return "Attachment \(index + 1): unknown MIME type for \(type.identifier)" case let .attachmentUpload(index: index, cause: cause): return "Attachment \(index + 1): \(cause.localizedDescription)" case let .posting(error): return error.localizedDescription } } } }