// // 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 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 : nil let sensitive = contentWarning != nil 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.visibility, 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 ? draft.localOnly : nil ) } 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 AttachmentData.Error { throw Error.attachmentData(index: index, cause: error) } do { let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) attachments.append(uploaded.id) currentStep += 1 } catch let error as Client.Error { throw Error.attachmentUpload(index: index, cause: error) } } 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(data: Data, utType: UTType, description: String?) async throws -> Attachment { let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)") let req = Client.upload(attachment: formAttachment, description: description) return try await mastodonController.run(req).0 } 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: AttachmentData.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 } } } }