From f51f3c8a9410feb9e045c1d191599014c3f1646e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 22 Apr 2023 21:16:30 -0400 Subject: [PATCH] Use CoreData for drafts store --- .../Sources/ComposeUI/API/PostService.swift | 13 +- .../Controllers/AttachmentRowController.swift | 10 +- .../AttachmentsListController.swift | 37 ++- .../Controllers/ComposeController.swift | 32 ++- .../Controllers/DraftsController.swift | 21 +- .../Controllers/PollController.swift | 18 +- .../Sources/ComposeUI/CoreData/Draft.swift | 70 +++++ .../ComposeUI/CoreData/DraftAttachment.swift | 270 ++++++++++++++++++ .../Drafts.xcdatamodel/contents | 39 +++ .../CoreData/DraftsPersistentContainer.swift | 126 ++++++++ .../Sources/ComposeUI/CoreData/Poll.swift | 44 +++ .../ComposeUI/CoreData/PollOption.swift | 21 ++ .../Sources/ComposeUI/DraftsMigrator.swift | 255 +++++++++++++++++ .../Sources/ComposeUI/Model/Draft.swift | 177 ------------ .../ComposeUI/Model/DraftAttachment.swift | 115 -------- .../ComposeUI/Model/DraftsManager.swift | 173 ----------- .../Sources/ComposeUI/TestView.swift | 36 +++ .../Views/AttachmentThumbnailView.swift | 40 ++- .../ComposeUI/Views/CurrentAccountView.swift | 2 +- .../Sources/ComposeUI/Views/HeaderView.swift | 9 +- .../ComposeUI/Views/PollOptionView.swift | 8 +- .../TuskerComponents/AvatarImageView.swift | 2 +- ShareExtension/ShareMastodonContext.swift | 2 +- ShareExtension/ShareViewController.swift | 23 +- Tusker/API/MastodonController.swift | 4 +- Tusker/AppDelegate.swift | 35 +-- Tusker/Scenes/ComposeSceneDelegate.swift | 2 +- Tusker/Scenes/MainSceneDelegate.swift | 4 +- .../Compose/ComposeHostingController.swift | 9 +- Tusker/Shortcuts/UserActivityManager.swift | 4 +- 30 files changed, 993 insertions(+), 608 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents create mode 100644 Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/CoreData/PollOption.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/DraftsMigrator.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/TestView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index 9a5bb91c..53b6e3b4 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -33,7 +33,7 @@ class PostService: ObservableObject { } // save before posting, so if a crash occurs during network request, the status won't be lost - DraftsManager.shared.save() + DraftsPersistentContainer.shared.save() let uploadedAttachments = try await uploadAttachments() @@ -49,7 +49,7 @@ class PostService: ObservableObject { spoilerText: contentWarning, visibility: draft.visibility, language: nil, - pollOptions: draft.poll?.options.map(\.text), + 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 @@ -58,17 +58,18 @@ class PostService: ObservableObject { let (_, _) = try await mastodonController.run(request) currentStep += 1 - DraftsManager.shared.remove(self.draft) - DraftsManager.shared.save() + DraftsPersistentContainer.shared.viewContext.delete(self.draft) + DraftsPersistentContainer.shared.save() } catch let error as Client.Error { throw Error.posting(error) } } + @MainActor private func uploadAttachments() async throws -> [Attachment] { var attachments: [Attachment] = [] attachments.reserveCapacity(draft.attachments.count) - for (index, attachment) in draft.attachments.enumerated() { + for (index, attachment) in draft.draftAttachments.enumerated() { let data: Data let utType: UTType do { @@ -90,7 +91,7 @@ class PostService: ObservableObject { private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) { return try await withCheckedThrowingContinuation { continuation in - attachment.data.getData(features: mastodonController.instanceFeatures) { result in + attachment.getData(features: mastodonController.instanceFeatures) { result in switch result { case let .success(res): continuation.resume(returning: res) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift index 4f09c9d9..a0c1b2b8 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift @@ -27,7 +27,7 @@ class AttachmentRowController: ViewController { private func removeAttachment() { withAnimation { - parent.draft.attachments.removeAll(where: { $0.id == attachment.id }) + parent.draft.attachments.remove(attachment) } } @@ -36,7 +36,7 @@ class AttachmentRowController: ViewController { return } parent.config.presentDrawing?(drawing) { newDrawing in - self.attachment.data = .drawing(newDrawing) + self.attachment.drawing = newDrawing } } @@ -44,7 +44,7 @@ class AttachmentRowController: ViewController { descriptionMode = .recognizingText DispatchQueue.global(qos: .userInitiated).async { - self.attachment.data.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in + self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in let data: Data switch result { case .success((let d, _)): @@ -103,11 +103,11 @@ class AttachmentRowController: ViewController { .frame(width: 80, height: 80) .cornerRadius(8) .contextMenu { - if case .drawing(_) = attachment.data { + if attachment.drawingData != nil { Button(action: controller.editDrawing) { Label("Edit Drawing", systemImage: "hand.draw") } - } else if attachment.data.type == .image { + } else if attachment.type == .image { Button(action: controller.recognizeText) { Label("Recognize Text", systemImage: "doc.text.viewfinder") } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift index 215506d0..9a81bc54 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift @@ -20,7 +20,7 @@ class AttachmentsListController: ViewController { private var requiresAttachmentDescriptions: Bool { if parent.config.requireAttachmentDescriptions { - return draft.attachments.allSatisfy { + return draft.draftAttachments.allSatisfy { !$0.attachmentDescription.isEmpty } } else { @@ -31,8 +31,8 @@ class AttachmentsListController: ViewController { private var validAttachmentCombination: Bool { if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { return true - } else if draft.attachments.contains(where: { $0.data.type == .video }) && - draft.attachments.count > 1 { + } else if draft.attachments.count > 1, + draft.draftAttachments.contains(where: { $0.type == .video }) { return false } else if draft.attachments.count > 4 { return false @@ -44,9 +44,9 @@ class AttachmentsListController: ViewController { self.parent = parent } - private var canAddAttachment: Bool { + var canAddAttachment: Bool { if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil + return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil } else { return true } @@ -56,7 +56,7 @@ class AttachmentsListController: ViewController { if parent.mastodonController.instanceFeatures.pollsAndAttachments { return true } else { - return draft.attachments.isEmpty + return draft.attachments.count == 0 } } @@ -65,11 +65,16 @@ class AttachmentsListController: ViewController { } private func moveAttachments(from source: IndexSet, to destination: Int) { - draft.attachments.move(fromOffsets: source, toOffset: destination) + // just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet + // results in the order switching back to the previous order and then to the correct one + // on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem + var array = draft.draftAttachments + array.move(fromOffsets: source, toOffset: destination) + draft.attachments = NSMutableOrderedSet(array: array) } private func deleteAttachments(at indices: IndexSet) { - draft.attachments.remove(atOffsets: indices) + draft.attachments.removeObjects(at: indices) } private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { @@ -78,7 +83,9 @@ class AttachmentsListController: ViewController { guard let attachment = object as? DraftAttachment else { return } DispatchQueue.main.async { guard self.canAddAttachment else { return } - self.draft.attachments.append(attachment) + DraftsPersistentContainer.shared.viewContext.insert(attachment) + attachment.draft = self.draft + self.draft.attachments.add(attachment) } } } @@ -92,7 +99,10 @@ class AttachmentsListController: ViewController { private func addDrawing() { parent.config.presentDrawing?(PKDrawing()) { drawing in - self.draft.attachments.append(DraftAttachment(data: .drawing(drawing))) + let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext) + attachment.drawing = drawing + attachment.draft = self.draft + self.draft.attachments.add(attachment) } } @@ -100,7 +110,7 @@ class AttachmentsListController: ViewController { UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) withAnimation { - draft.poll = draft.poll == nil ? Draft.Poll() : nil + draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil } } @@ -133,12 +143,9 @@ class AttachmentsListController: ViewController { } private var attachmentsList: some View { - ForEach(draft.attachments) { attachment in + ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) }) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - .onDrag { - NSItemProvider(object: attachment) - } } .onMove(perform: controller.moveAttachments) .onDelete(perform: controller.deleteAttachments) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 79a73b21..5d1b66d3 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -23,7 +23,7 @@ public final class ComposeController: ViewController { let fetchAvatar: AvatarImageView.FetchAvatar let fetchStatus: FetchStatus let displayNameLabel: DisplayNameLabel - let currentAccountContainerview: CurrentAccountContainerView + let currentAccountContainerView: CurrentAccountContainerView let replyContentView: ReplyContentView let emojiImageView: EmojiImageView @@ -63,7 +63,7 @@ public final class ComposeController: ViewController { } private var isPollValid: Bool { - draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty } + draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty } } public init( @@ -83,7 +83,7 @@ public final class ComposeController: ViewController { self.fetchAvatar = fetchAvatar self.fetchStatus = fetchStatus self.displayNameLabel = displayNameLabel - self.currentAccountContainerview = currentAccountContainerView + self.currentAccountContainerView = currentAccountContainerView self.replyContentView = replyContentView self.emojiImageView = emojiImageView @@ -94,6 +94,7 @@ public final class ComposeController: ViewController { public var view: some View { ComposeView(poster: poster) + .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) .environmentObject(draft) .environmentObject(mastodonController.instanceFeatures) } @@ -103,7 +104,7 @@ public final class ComposeController: ViewController { return false } if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - if draft.attachments.allSatisfy({ $0.data.type == .image }) { + if draft.draftAttachments.allSatisfy({ $0.type == .image }) { // if providers are videos, this technically allows invalid video/image combinations return itemProviders.count + draft.attachments.count <= 4 } else { @@ -119,7 +120,10 @@ public final class ComposeController: ViewController { provider.loadObject(ofClass: DraftAttachment.self) { object, error in guard let attachment = object as? DraftAttachment else { return } DispatchQueue.main.async { - self.draft.attachments.append(attachment) + guard self.attachmentsListController.canAddAttachment else { return } + DraftsPersistentContainer.shared.viewContext.insert(attachment) + attachment.draft = self.draft + self.draft.attachments.add(attachment) } } } @@ -133,7 +137,7 @@ public final class ComposeController: ViewController { if draft.hasContent { isShowingSaveDraftSheet = true } else { - DraftsManager.shared.remove(draft) + DraftsPersistentContainer.shared.viewContext.delete(draft) config.dismiss(.cancel) } } @@ -142,7 +146,7 @@ public final class ComposeController: ViewController { @MainActor func cancel(deleteDraft: Bool) { if deleteDraft { - DraftsManager.shared.remove(draft) + DraftsPersistentContainer.shared.viewContext.delete(draft) } config.dismiss(.cancel) } @@ -185,20 +189,20 @@ public final class ComposeController: ViewController { isShowingDraftsList = true } - func selectDraft(_ draft: Draft) { + func selectDraft(_ newDraft: Draft) { if !self.draft.hasContent { - DraftsManager.shared.remove(self.draft) + DraftsPersistentContainer.shared.viewContext.delete(self.draft) } - DraftsManager.shared.save() + DraftsPersistentContainer.shared.save() - self.draft = draft + self.draft = newDraft } func onDisappear() { if !draft.hasContent { - DraftsManager.shared.remove(draft) + DraftsPersistentContainer.shared.viewContext.delete(draft) } - DraftsManager.shared.save() + DraftsPersistentContainer.shared.save() } func toggleContentWarning() { @@ -296,7 +300,7 @@ public final class ComposeController: ViewController { .listRowBackground(config.backgroundColor) } - HeaderView() + HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining) .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) .listRowBackground(config.backgroundColor) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift index da97bce3..71f8bd60 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift @@ -43,12 +43,12 @@ class DraftsController: ViewController { } func deleteDraft(_ draft: Draft) { - DraftsManager.shared.remove(draft) + DraftsPersistentContainer.shared.viewContext.delete(draft) } func closeDrafts() { isPresented = false - DraftsManager.shared.save() + DraftsPersistentContainer.shared.save() } struct DraftsRepresentable: UIViewControllerRepresentable { @@ -65,18 +65,12 @@ class DraftsController: ViewController { struct DraftsView: View { @EnvironmentObject private var controller: DraftsController @EnvironmentObject private var currentDraft: Draft - @ObservedObject private var draftsManager = DraftsManager.shared - - private var visibleDrafts: [Draft] { - draftsManager.sorted.filter { - $0.accountID == controller.parent.mastodonController.accountInfo!.id && $0.id != currentDraft.id - } - } + @FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults var body: some View { NavigationView { List { - ForEach(visibleDrafts) { draft in + ForEach(drafts) { draft in Button(action: { controller.maybeSelectDraft(draft) }) { DraftRow(draft: draft) } @@ -90,7 +84,7 @@ class DraftsController: ViewController { }) } .onDelete { indices in - indices.map { visibleDrafts[$0] }.forEach(controller.deleteDraft) + indices.map { drafts[$0] }.forEach(controller.deleteDraft) } } .listStyle(.plain) @@ -110,6 +104,9 @@ class DraftsController: ViewController { } message: { _ in Text("The selected draft is a reply to a different post, do you wish to use it?") } + .onAppear { + drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID) + } } private var cancelButton: some View { @@ -136,7 +133,7 @@ private struct DraftRow: View { .font(.body) HStack(spacing: 8) { - ForEach(draft.attachments) { attachment in + ForEach(draft.draftAttachments) { attachment in AttachmentThumbnailView(attachment: attachment, fullSize: false) .frame(width: 50, height: 50) .cornerRadius(5) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift index c13b8d85..d3d0fd48 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift @@ -12,11 +12,11 @@ class PollController: ViewController { unowned let parent: ComposeController var draft: Draft { parent.draft } - let poll: Draft.Poll + let poll: Poll @Published var duration: Duration - init(parent: ComposeController, poll: Draft.Poll) { + init(parent: ComposeController, poll: Poll) { self.parent = parent self.poll = poll self.duration = .fromTimeInterval(poll.duration) ?? .oneDay @@ -34,11 +34,11 @@ class PollController: ViewController { } private func moveOptions(indices: IndexSet, newIndex: Int) { - poll.options.move(fromOffsets: indices, toOffset: newIndex) + poll.options.moveObjects(at: indices, to: newIndex) } - private func removeOption(_ option: Draft.Poll.Option) { - poll.options.removeAll(where: { $0.id == option.id }) + private func removeOption(_ option: PollOption) { + poll.options.remove(option) } private var canAddOption: Bool { @@ -50,12 +50,14 @@ class PollController: ViewController { } private func addOption() { - poll.options.append(.init("")) + let option = PollOption(context: DraftsPersistentContainer.shared.viewContext) + option.poll = poll + poll.options.add(option) } struct PollView: View { @EnvironmentObject private var controller: PollController - @EnvironmentObject private var poll: Draft.Poll + @EnvironmentObject private var poll: Poll @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -79,7 +81,7 @@ class PollController: ViewController { } List { - ForEach(poll.options) { option in + ForEach($poll.pollOptions) { $option in PollOptionView(option: option, remove: { controller.removeOption(option) }) .frame(height: 36) .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift new file mode 100644 index 00000000..19501344 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift @@ -0,0 +1,70 @@ +// +// Draft.swift +// ComposeUI +// +// Created by Shadowfacts on 4/22/23. +// + +import CoreData +import Pachyderm + +@objc +public class Draft: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Draft") + } + + @nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "Draft") + req.predicate = NSPredicate(format: "id = %@", id as NSUUID) + return req + } + + @NSManaged public var accountID: String + @NSManaged public var contentWarning: String + @NSManaged public var contentWarningEnabled: Bool + @NSManaged public var id: UUID + @NSManaged public var initialText: String + @NSManaged public var inReplyToID: String? + @NSManaged public var lastModified: Date + @NSManaged public var localOnly: Bool + @NSManaged public var text: String + @NSManaged private var visibilityStr: String + + @NSManaged internal var attachments: NSMutableOrderedSet + @NSManaged public var poll: Poll? + + public var visibility: Visibility { + get { + Visibility(rawValue: visibilityStr) ?? .public + } + set { + visibilityStr = newValue.rawValue + } + } + + public var draftAttachments: [DraftAttachment] { + get { + attachments.array as! [DraftAttachment] + } + set { + attachments = NSMutableOrderedSet(array: newValue) + } + } + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + lastModified = Date() + } + +} + +extension Draft { + public var hasContent: Bool { + (!text.isEmpty && text != initialText) || + (contentWarningEnabled && !contentWarning.isEmpty) || + attachments.count > 0 || + poll?.hasContent == true + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift new file mode 100644 index 00000000..d52032b9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift @@ -0,0 +1,270 @@ +// +// DraftAttachment.swift +// CoreData +// +// Created by Shadowfacts on 4/22/23. +// + +import CoreData +import PencilKit +import UniformTypeIdentifiers +import Photos +import InstanceFeatures + +private let decoder = PropertyListDecoder() +private let encoder = PropertyListEncoder() + +@objc +public final class DraftAttachment: NSManagedObject, Identifiable { + + @NSManaged internal var assetID: String? + @NSManaged public var attachmentDescription: String + @NSManaged internal private(set) var drawingData: Data? + @NSManaged public var fileURL: URL? + @NSManaged internal var fileType: String? + @NSManaged public var id: UUID + + @NSManaged internal var draft: Draft + + public var drawing: PKDrawing? { + get { + if let drawingData, + let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) { + return drawing + } else { + return nil + } + } + set { + drawingData = try! encoder.encode(newValue) + } + } + + public var data: AttachmentData { + if let assetID { + return .asset(assetID) + } else if let drawing { + return .drawing(drawing) + } else if let fileURL, let fileType { + return .file(fileURL, UTType(fileType)!) + } else { + fatalError() + } + } + + public enum AttachmentData { + case asset(String) + case drawing(PKDrawing) + case file(URL, UTType) + } + +} + +extension DraftAttachment { + var type: AttachmentType { + if let assetID { + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else { + return .unknown + } + switch asset.mediaType { + case .image: + return .image + case .video: + return .video + default: + return .unknown + } + } else if drawingData != nil { + return .image + } else if let fileType, + let type = UTType(fileType) { + if type.conforms(to: .image) { + return .image + } else if type.conforms(to: .movie) { + return .video + } else { + return .unknown + } + } else { + return .unknown + } + } + + enum AttachmentType { + case image, video, unknown + } +} + +//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment" + +private let jpegType = UTType.jpeg.identifier +private let pngType = UTType.png.identifier +private let mp4Type = UTType.mpeg4Movie.identifier +private let quickTimeType = UTType.quickTimeMovie.identifier +private let gifType = UTType.gif.identifier + +extension DraftAttachment: NSItemProviderReading { + public static var readableTypeIdentifiersForItemProvider: [String] { + // todo: is there a better way of handling movies than manually adding all possible UTI types? + // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension + // without the file extension, getting the thumbnail and exporting the video for attachment upload fails + [/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType] + } + + public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { + let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil) + attachment.id = UUID() + attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!) + attachment.fileType = typeIdentifier + attachment.attachmentDescription = "" + return attachment + } + + static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL { + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! + let directoryURL = containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments") + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type) + try data.write(to: attachmentURL) + return attachmentURL + } +} + +// MARK: Exporting + +extension DraftAttachment { + func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) { + if let assetID { + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else { + completion(.failure(.noAsset)) + return + } + if asset.mediaType == .image { + let options = PHImageRequestOptions() + options.version = .current + options.deliveryMode = .highQualityFormat + options.resizeMode = .none + options.isNetworkAccessAllowed = true + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in + guard let data, let dataUTI else { + completion(.failure(.missingAssetData)) + return + } + let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion) + completion(.success(processed)) + } + } else if asset.mediaType == .video { + let options = PHVideoRequestOptions() + options.version = .current + options.deliveryMode = .automatic + options.isNetworkAccessAllowed = true + PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in + if let exportSession { + Self.exportVideoData(session: exportSession, completion: completion) + } else if let error = info?[PHImageErrorKey] as? Error { + completion(.failure(.videoExport(error))) + } else { + completion(.failure(.noVideoExportSession)) + } + } + } else { + completion(.failure(.unknownAssetType)) + } + } else if let drawingData { + guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else { + completion(.failure(.loadingDrawing)) + return + } + let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) + completion(.success((image.pngData()!, .png))) + } else if let fileURL, let fileType { + let type = UTType(fileType)! + + if type.conforms(to: .movie) { + let asset = AVURLAsset(url: fileURL) + guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { + completion(.failure(.noVideoExportSession)) + return + } + Self.exportVideoData(session: session, completion: completion) + } else { + let fileData: Data + do { + fileData = try Data(contentsOf: fileURL) + } catch { + completion(.failure(.loadingData)) + return + } + + if type.conforms(to: .image) { + let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion) + completion(.success(result)) + } else { + completion(.success((fileData, type))) + } + } + } + } + + private static func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) { + guard !skipAllConversion else { + return (data, type) + } + + var data = data + var type = type + + if type != .png && type != .jpeg, + let image = UIImage(data: data) { + // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. + data = image.jpegData(compressionQuality: 0.8)! + type = .jpeg + } + + let image = CIImage(data: data)! + let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB + + // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG + // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) + // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion + if needsColorSpaceConversion || type == .heic { + let context = CIContext() + let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! + if type == .png { + data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)! + } else { + data = context.jpegRepresentation(of: image, colorSpace: colorSpace)! + type = .jpeg + } + } + + return (data, type) + } + + private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) { + session.outputFileType = .mp4 + session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") + session.exportAsynchronously { + guard session.status == .completed else { + completion(.failure(.videoExport(session.error!))) + return + } + do { + let data = try Data(contentsOf: session.outputURL!) + completion(.success((data, .mpeg4Movie))) + } catch { + completion(.failure(.videoExport(error))) + } + } + } + + enum ExportError: Error { + case noAsset + case unknownAssetType + case missingAssetData + case videoExport(Error) + case noVideoExportSession + case loadingDrawing + case loadingData + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents new file mode 100644 index 00000000..667bed3f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift new file mode 100644 index 00000000..939a7484 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift @@ -0,0 +1,126 @@ +// +// DraftsPersistentContainer.swift +// ComposeUI +// +// Created by Shadowfacts on 4/22/23. +// + +import Foundation +import CoreData +import OSLog +import Pachyderm + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer") + +public class DraftsPersistentContainer: NSPersistentContainer { + + public static let shared = DraftsPersistentContainer() + + private static let managedObjectModel: NSManagedObjectModel = { + let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")! + return NSManagedObjectModel(contentsOf: url)! + }() + + private var lastHistoryToken: NSPersistentHistoryToken! + + init() { + super.init(name: "Drafts", managedObjectModel: DraftsPersistentContainer.managedObjectModel) + + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! + let documentsURL = containerURL.appendingPathComponent("Documents") + let storeDesc = NSPersistentStoreDescription(url: documentsURL.appendingPathComponent("drafts").appendingPathExtension("sqlite")) + storeDesc.type = NSSQLiteStoreType + storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + + persistentStoreDescriptions = [ + storeDesc + ] + + loadPersistentStores { _, error in + if let error { + fatalError("Loading persistent store: \(error)") + } + } + + viewContext.automaticallyMergesChangesFromParent = true + viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + + lastHistoryToken = persistentStoreCoordinator.currentPersistentHistoryToken(fromStores: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges(_:)), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator) + } + + public func save() { + guard viewContext.hasChanges else { + return + } + do { + try viewContext.save() + } catch { + logger.error("Failed to save: \(String(describing: error))") + } + } + + public func migrate(from url: URL, completion: @escaping (Result<(), any Error>) -> Void) { + performBackgroundTask { context in + let result = DraftsMigrator.migrate(from: url, to: context) + completion(result) + try! context.save() + } + } + + public func getDraft(id: UUID) -> Draft? { + let req = Draft.fetchRequest(id: id) + return try? viewContext.fetch(req).first + } + + public func createDraft( + accountID: String, + text: String, + contentWarning: String, + inReplyToID: String?, + visibility: Visibility, + localOnly: Bool + ) -> Draft { + let draft = Draft(context: viewContext) + draft.accountID = accountID + draft.text = text + draft.initialText = text + draft.contentWarning = contentWarning + draft.contentWarningEnabled = !contentWarning.isEmpty + draft.inReplyToID = inReplyToID + draft.visibility = visibility + draft.localOnly = localOnly + save() + return draft + } + + @objc private func remoteChanges(_ notification: Foundation.Notification) { + guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { + return + } + + // todo: should this be on a background context? + let context = viewContext + context.perform { + let predicate = NSPredicate(format: "(%@ < token) AND (token <= %@)", self.lastHistoryToken, newHistoryToken) + + let historyRequest = NSPersistentHistoryTransaction.fetchRequest! + historyRequest.predicate = predicate + let request = NSPersistentHistoryChangeRequest.fetchHistory(withFetch: historyRequest) + if let result = try? context.execute(request) as? NSPersistentHistoryResult, + let transactions = result.result as? [NSPersistentHistoryTransaction] { + for transaction in transactions { + guard let userInfo = transaction.objectIDNotification().userInfo else { + continue + } + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context]) + } + } + + self.lastHistoryToken = newHistoryToken + } + } + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift new file mode 100644 index 00000000..4cdb02fd --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift @@ -0,0 +1,44 @@ +// +// Poll.swift +// ComposeUI +// +// Created by Shadowfacts on 4/22/23. +// + +import CoreData + +@objc +public class Poll: NSManagedObject { + + @NSManaged public var duration: TimeInterval + @NSManaged public var multiple: Bool + + @NSManaged public var draft: Draft + @NSManaged public var options: NSMutableOrderedSet + + init(context: NSManagedObjectContext) { + super.init(entity: context.persistentStoreCoordinator!.managedObjectModel.entitiesByName["Poll"]!, insertInto: context) + self.multiple = false + self.duration = 24 * 60 * 60 // 1 day + self.options = [ + PollOption(context: context), + PollOption(context: context), + ] + } + + public var pollOptions: [PollOption] { + get { + options.array as! [PollOption] + } + set { + options = NSMutableOrderedSet(array: newValue) + } + } + +} + +extension Poll { + public var hasContent: Bool { + pollOptions.allSatisfy { !$0.text.isEmpty } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/PollOption.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/PollOption.swift new file mode 100644 index 00000000..c7386c92 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/PollOption.swift @@ -0,0 +1,21 @@ +// +// PollOption.swift +// ComposeUI +// +// Created by Shadowfacts on 4/22/23. +// + +import CoreData + +@objc +public class PollOption: NSManagedObject, Identifiable { + + public var id: NSManagedObjectID { + objectID + } + + @NSManaged public var text: String + + @NSManaged public var poll: Poll + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/DraftsMigrator.swift b/Packages/ComposeUI/Sources/ComposeUI/DraftsMigrator.swift new file mode 100644 index 00000000..29260182 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/DraftsMigrator.swift @@ -0,0 +1,255 @@ +// +// DraftsMigrator.swift +// ComposeUI +// +// Created by Shadowfacts on 4/22/23. +// + +import Foundation +import OSLog +import UniformTypeIdentifiers +import Pachyderm +import PencilKit +import CoreData + +struct DraftsMigrator { + private init() {} + + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsMigrator") + private static let decoder = PropertyListDecoder() + + static func migrate(from url: URL, to context: NSManagedObjectContext) -> Result<(), any Error> { + do { + let data = try Data(contentsOf: url) + let container = try decoder.decode(DraftsContainer.self, from: data) + for old in container.drafts.values { + let new = Draft(context: context) + new.id = old.id + new.lastModified = old.lastModified + new.accountID = old.accountID + new.text = old.text + new.contentWarningEnabled = old.contentWarningEnabled + new.contentWarning = old.contentWarning + new.inReplyToID = old.inReplyToID + new.visibility = old.visibility + new.localOnly = old.localOnly + new.initialText = old.initialText + + if let oldPoll = old.poll { + let newPoll = Poll(context: context) + newPoll.draft = new + new.poll = newPoll + newPoll.multiple = oldPoll.multiple + newPoll.duration = oldPoll.duration + for oldOption in oldPoll.options { + let newOption = PollOption(context: context) + newOption.text = oldOption.text + newOption.poll = newPoll + newPoll.options.add(newOption) + } + } + + for oldAttachment in old.attachments { + let newAttachment = DraftAttachment(context: context) + newAttachment.draft = new + new.attachments.add(newAttachment) + newAttachment.id = oldAttachment.id + newAttachment.attachmentDescription = oldAttachment.attachmentDescription + switch oldAttachment.data { + case .asset(let assetID): + newAttachment.assetID = assetID + case .image(let data, originalType: let type): + newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: type) + newAttachment.fileType = type.identifier + case .video(_): + fatalError("unreachable, video attachments weren't encodable") + case .drawing(let drawing): + newAttachment.drawing = drawing + case .gif(let data): + newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: .gif) + newAttachment.fileType = UTType.gif.identifier + } + } + } + + try FileManager.default.removeItem(at: url) + } catch { + logger.error("Error migrating: \(String(describing: error))") + return .failure(error) + } + return .success(()) + } + + // MARK: Supporting Types + + struct DraftsContainer: Decodable { + let drafts: [UUID: OldDraft] + + init(drafts: [UUID: OldDraft]) { + self.drafts = drafts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft) + } + + enum CodingKeys: CodingKey { + case drafts + } + } + + // a container that always succeeds at decoding + // so if a single draft can't be decoded, we don't lose all drafts + struct SafeDraft: Decodable { + let draft: OldDraft? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.draft = try? container.decode(OldDraft.self) + } + } + + struct OldDraft: Decodable { + let id: UUID + let lastModified: Date + let accountID: String + let text: String + let contentWarningEnabled: Bool + let contentWarning: String + let attachments: [OldDraftAttachment] + let inReplyToID: String? + let visibility: Visibility + let poll: OldPoll? + let localOnly: Bool + let initialText: String + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.lastModified = try container.decode(Date.self, forKey: .lastModified) + + self.accountID = try container.decode(String.self, forKey: .accountID) + self.text = try container.decode(String.self, forKey: .text) + self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled) + self.contentWarning = try container.decode(String.self, forKey: .contentWarning) + self.attachments = try container.decode([OldDraftAttachment].self, forKey: .attachments) + self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) + self.visibility = try container.decode(Visibility.self, forKey: .visibility) + self.poll = try container.decode(OldPoll?.self, forKey: .poll) + self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false + + self.initialText = try container.decode(String.self, forKey: .initialText) + } + + enum CodingKeys: String, CodingKey { + case id + case lastModified + + case accountID + case text + case contentWarningEnabled + case contentWarning + case attachments + case inReplyToID + case visibility + case poll + case localOnly + + case initialText + } + } + + struct OldDraftAttachment: Decodable { + let id: UUID + let data: OldDraftAttachmentData + let attachmentDescription: String + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.data = try container.decode(OldDraftAttachmentData.self, forKey: .data) + self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription) + } + + enum CodingKeys: String, CodingKey { + case id + case data + case attachmentDescription + } + } + + enum OldDraftAttachmentData: Decodable { + case asset(String) + case image(Data, originalType: UTType) + case video(URL) + case drawing(PKDrawing) + case gif(Data) + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + switch try container.decode(String.self, forKey: .type) { + case "asset": + let identifier = try container.decode(String.self, forKey: .assetIdentifier) + self = .asset(identifier) + case "image": + let data = try container.decode(Data.self, forKey: .imageData) + if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) { + self = .image(data, originalType: type) + } else { + guard let image = UIImage(data: data) else { + throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage") + } + let jpegData = image.jpegData(compressionQuality: 1)! + self = .image(jpegData, originalType: .jpeg) + } + case "drawing": + let drawingData = try container.decode(Data.self, forKey: .drawing) + let drawing = try PKDrawing(data: drawingData) + self = .drawing(drawing) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing") + } + } + + enum CodingKeys: CodingKey { + case type + case imageData + case imageType + /// The local identifier of the PHAsset for this attachment + case assetIdentifier + /// The PKDrawing object for this attachment. + case drawing + } + } + + struct OldPoll: Decodable { + let options: [OldPollOption] + let multiple: Bool + let duration: TimeInterval + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.options = try container.decode([OldPollOption].self, forKey: .options) + self.multiple = try container.decode(Bool.self, forKey: .multiple) + self.duration = try container.decode(TimeInterval.self, forKey: .duration) + } + + enum CodingKeys: String, CodingKey { + case options + case multiple + case duration + } + } + + struct OldPollOption: Decodable { + let text: String + + init(from decoder: Decoder) throws { + self.text = try decoder.singleValueContainer().decode(String.self) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift deleted file mode 100644 index 0d24852f..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// Draft.swift -// ComposeUI -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import Foundation -import Combine -import Pachyderm - -public class Draft: Codable, Identifiable, ObservableObject { - public let id: UUID - var lastModified: Date - - @Published public var accountID: String - @Published public var text: String - @Published public var contentWarningEnabled: Bool - @Published public var contentWarning: String - @Published public var attachments: [DraftAttachment] - @Published public var inReplyToID: String? - @Published public var visibility: Visibility - @Published public var poll: Poll? - @Published public var localOnly: Bool - - var initialText: String - - public var hasContent: Bool { - (!text.isEmpty && text != initialText) || - (contentWarningEnabled && !contentWarning.isEmpty) || - attachments.count > 0 || - poll?.hasContent == true - } - - public init( - accountID: String, - text: String, - contentWarning: String, - inReplyToID: String?, - visibility: Visibility, - localOnly: Bool - ) { - self.id = UUID() - self.lastModified = Date() - - self.accountID = accountID - self.text = text - self.contentWarning = contentWarning - self.contentWarningEnabled = !contentWarning.isEmpty - self.attachments = [] - self.inReplyToID = inReplyToID - self.visibility = visibility - self.localOnly = localOnly - - self.initialText = text - } - - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self.id = try container.decode(UUID.self, forKey: .id) - self.lastModified = try container.decode(Date.self, forKey: .lastModified) - - self.accountID = try container.decode(String.self, forKey: .accountID) - self.text = try container.decode(String.self, forKey: .text) - self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled) - self.contentWarning = try container.decode(String.self, forKey: .contentWarning) - self.attachments = try container.decode([DraftAttachment].self, forKey: .attachments) - self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) - self.visibility = try container.decode(Visibility.self, forKey: .visibility) - self.poll = try container.decode(Poll?.self, forKey: .poll) - self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false - - self.initialText = try container.decode(String.self, forKey: .initialText) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(id, forKey: .id) - try container.encode(lastModified, forKey: .lastModified) - - try container.encode(accountID, forKey: .accountID) - try container.encode(text, forKey: .text) - try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled) - try container.encode(contentWarning, forKey: .contentWarning) - try container.encode(attachments, forKey: .attachments) - try container.encode(inReplyToID, forKey: .inReplyToID) - try container.encode(visibility, forKey: .visibility) - try container.encode(poll, forKey: .poll) - try container.encode(localOnly, forKey: .localOnly) - - try container.encode(initialText, forKey: .initialText) - } -} - -extension Draft: Equatable { - public static func ==(lhs: Draft, rhs: Draft) -> Bool { - return lhs.id == rhs.id - } -} - -extension Draft { - enum CodingKeys: String, CodingKey { - case id - case lastModified - - case accountID - case text - case contentWarningEnabled - case contentWarning - case attachments - case inReplyToID - case visibility - case poll - case localOnly - - case initialText - } -} - -extension Draft { - public class Poll: Codable, ObservableObject { - @Published public var options: [Option] - @Published public var multiple: Bool - @Published public var duration: TimeInterval - - var hasContent: Bool { - options.contains { !$0.text.isEmpty } - } - - public init() { - self.options = [Option(""), Option("")] - self.multiple = false - self.duration = 24 * 60 * 60 // 1 day - } - - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.options = try container.decode([Option].self, forKey: .options) - self.multiple = try container.decode(Bool.self, forKey: .multiple) - self.duration = try container.decode(TimeInterval.self, forKey: .duration) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(options, forKey: .options) - try container.encode(multiple, forKey: .multiple) - try container.encode(duration, forKey: .duration) - } - - private enum CodingKeys: String, CodingKey { - case options - case multiple - case duration - } - - public class Option: Identifiable, Codable, ObservableObject { - public let id = UUID() - @Published public var text: String - - init(_ text: String) { - self.text = text - } - - public required init(from decoder: Decoder) throws { - self.text = try decoder.singleValueContainer().decode(String.self) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(text) - } - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift deleted file mode 100644 index 4becbc46..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// DraftAttachment.swift -// ComposeUI -// -// Created by Shadowfacts on 3/14/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import Foundation -import UIKit -import UniformTypeIdentifiers - -public final class DraftAttachment: NSObject, Codable, ObservableObject, Identifiable { - static let typeIdentifier = "space.vaccor.Tusker.composition-attachment" - - public let id: UUID - @Published var data: AttachmentData - @Published var attachmentDescription: String - - init(data: AttachmentData, description: String = "") { - self.id = UUID() - self.data = data - self.attachmentDescription = description - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self.id = try container.decode(UUID.self, forKey: .id) - self.data = try container.decode(AttachmentData.self, forKey: .data) - self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(id, forKey: .id) - try container.encode(data, forKey: .data) - try container.encode(attachmentDescription, forKey: .attachmentDescription) - } - - static func ==(lhs: DraftAttachment, rhs: DraftAttachment) -> Bool { - return lhs.id == rhs.id - } - - enum CodingKeys: String, CodingKey { - case id - case data - case attachmentDescription - } -} - -private let jpegType = UTType.jpeg.identifier -private let pngType = UTType.png.identifier -private let mp4Type = UTType.mpeg4Movie.identifier -private let quickTimeType = UTType.quickTimeMovie.identifier -private let gifType = UTType.gif.identifier - -extension DraftAttachment: NSItemProviderWriting { - public static var writableTypeIdentifiersForItemProvider: [String] { - [typeIdentifier] - } - - public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? { - if typeIdentifier == DraftAttachment.typeIdentifier { - do { - completionHandler(try PropertyListEncoder().encode(self), nil) - } catch { - completionHandler(nil, error) - } - } else { - completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) - } - return nil - } - - enum ItemProviderError: Error { - case incompatibleTypeIdentifier - - var localizedDescription: String { - switch self { - case .incompatibleTypeIdentifier: - return "Cannot provide data for given type" - } - } - } -} - -extension DraftAttachment: NSItemProviderReading { - public static var readableTypeIdentifiersForItemProvider: [String] { - // todo: is there a better way of handling movies than manually adding all possible UTI types? - // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension - // without the file extension, getting the thumbnail and exporting the video for attachment upload fails - [typeIdentifier, gifType, jpegType, pngType, mp4Type, quickTimeType] - } - - public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { - if typeIdentifier == DraftAttachment.typeIdentifier { - return try PropertyListDecoder().decode(DraftAttachment.self, from: data) - } else if typeIdentifier == gifType { - return DraftAttachment(data: .gif(data)) - } else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier) { - return DraftAttachment(data: .image(data, originalType: UTType(typeIdentifier)!)) - } else if typeIdentifier == mp4Type || typeIdentifier == quickTimeType { - let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let temporaryFileName = ProcessInfo().globallyUniqueString - let fileExt = UTType(typeIdentifier)!.preferredFilenameExtension! - let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt) - try data.write(to: temporaryFileURL) - return DraftAttachment(data: .video(temporaryFileURL)) - } else { - throw ItemProviderError.incompatibleTypeIdentifier - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift deleted file mode 100644 index 98cc9d16..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// DraftsManager.swift -// ComposeUI -// -// Created by Shadowfacts on 10/22/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import Foundation -import Combine -import OSLog - -private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsManager") -private let encoder = PropertyListEncoder() -private let decoder = PropertyListDecoder() - -public class DraftsManager: NSObject, ObservableObject, NSFilePresenter { - public private(set) static var shared: DraftsManager = { - let draftsManager = DraftsManager(url: DraftsManager.archiveURL) - draftsManager.load() - return draftsManager - }() - - private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! - private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - - private var url: URL - - @Published private var drafts: [UUID: Draft] = [:] - - public init(url: URL) { - self.url = url - - super.init() - } - - private func merge(from container: DraftsContainer) { - for draft in container.drafts.values { - if let existing = self.drafts[draft.id] { - existing.merge(from: draft) - } else { - self.drafts[draft.id] = draft - } - } - for id in self.drafts.keys where !container.drafts.keys.contains(id) { - self.drafts.removeValue(forKey: id) - } - } - - public func load(completion: ((Error?) -> Void)? = nil) { - NSFileCoordinator(filePresenter: self).coordinate(readingItemAt: self.url, options: [], error: nil) { url in - do { - let data = try Data(contentsOf: url) - let container = try decoder.decode(DraftsContainer.self, from: data) - DispatchQueue.main.async { - self.merge(from: container) - completion?(nil) - } - } catch { - logger.error("Error loading: \(String(describing: error))") - completion?(error) - } - } - } - - public func migrate(from url: URL) -> Result { - do { - let data = try Data(contentsOf: url) - let container = try decoder.decode(DraftsContainer.self, from: data) - self.merge(from: container) - self.save() - } catch { - logger.error("Error migrating: \(String(describing: error))") - return .failure(error) - } - return .success(()) - } - - public func save(completion: ((Error?) -> Void)? = nil) { - NSFileCoordinator(filePresenter: self).coordinate(writingItemAt: Self.archiveURL, options: .forReplacing, error: nil) { url in - do { - let data = try encoder.encode(DraftsContainer(drafts: self.drafts)) - try data.write(to: url, options: .atomic) - completion?(nil) - } catch { - logger.error("Error saving: \(String(describing: error))") - completion?(error) - } - } - } - - // MARK: Drafts API - - var sorted: [Draft] { - return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) - } - - public func add(_ draft: Draft) { - drafts[draft.id] = draft - } - - public func remove(_ draft: Draft) { - drafts.removeValue(forKey: draft.id) - } - - public func getBy(id: UUID) -> Draft? { - return drafts[id] - } - - // MARK: NSFilePresenter - - public var presentedItemURL: URL? { - url - } - - public let presentedItemOperationQueue = OperationQueue() - - public func presentedItemDidMove(to newURL: URL) { - self.url = newURL - } - - public func presentedItemDidChange() { - self.load() - } - - // MARK: Supporting Types - - struct DraftsContainer: Codable { - let drafts: [UUID: Draft] - - init(drafts: [UUID: Draft]) { - self.drafts = drafts - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft) - } - - enum CodingKeys: CodingKey { - case drafts - } - } - - // a container that always succeeds at decoding - // so if a single draft can't be decoded, we don't lose all drafts - struct SafeDraft: Decodable { - let draft: Draft? - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.draft = try? container.decode(Draft.self) - } - } -} - -private extension Draft { - func merge(from other: Draft) { - self.lastModified = other.lastModified - - self.accountID = other.accountID - self.text = other.text - self.contentWarningEnabled = other.contentWarningEnabled - self.contentWarning = other.contentWarning - self.attachments = other.attachments - self.inReplyToID = other.inReplyToID - self.visibility = other.visibility - self.poll = other.poll - self.localOnly = other.localOnly - - self.initialText = other.initialText - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/TestView.swift b/Packages/ComposeUI/Sources/ComposeUI/TestView.swift new file mode 100644 index 00000000..ffca6639 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/TestView.swift @@ -0,0 +1,36 @@ +// +// File.swift +// +// +// Created by Shadowfacts on 4/22/23. +// + +import SwiftUI + +struct TestView: View { + @State var manager = DraftsPersistentContainer() + + var body: some View { + VStack { + Button("Add") { + let entity = TestEntity(context: manager.viewContext) + entity.id = UUID() + try! manager.viewContext.save() + } + InnerView() + .environment(\.managedObjectContext, manager.viewContext) + } + } +} + +struct InnerView: View { + @FetchRequest(sortDescriptors: []) var results: FetchedResults + + var body: some View { + List { + ForEach(results) { result in + Text(result.id?.uuidString ?? "") + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift index caeaaac9..77c09471 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -48,9 +48,10 @@ struct AttachmentThumbnailView: View { private func loadImage() { switch attachment.data { - case let .image(originalData, originalType: _): - self.image = UIImage(data: originalData) - case let .asset(asset): + case .asset(let id): + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else { + return + } let size: CGSize if fullSize { size = PHImageManagerMaximumSize @@ -77,18 +78,35 @@ struct AttachmentThumbnailView: View { } } } - case let .video(url): - let asset = AVURLAsset(url: url) - let imageGenerator = AVAssetImageGenerator(asset: asset) - if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { - self.image = UIImage(cgImage: cgImage) - } + case let .drawing(drawing): image = drawing.imageInLightMode(from: drawing.bounds) imageContentMode = .fit imageBackgroundColor = .white - case let .gif(data): - self.gifData = data + + case .file(let url, let type): + if type.conforms(to: .movie) { + let asset = AVURLAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { + self.image = UIImage(cgImage: cgImage) + } + } else if let data = try? Data(contentsOf: url) { + if type == .gif { + self.gifData = data + } else if type.conforms(to: .image), + let image = UIImage(data: data) { + if fullSize { + image.prepareForDisplay { + self.image = $0 + } + } else { + image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { + self.image = $0 + } + } + } + } } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift index 064fef95..fae4acaf 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift @@ -14,7 +14,7 @@ struct CurrentAccountView: View { @EnvironmentObject private var controller: ComposeController var body: some View { - controller.currentAccountContainerview(AnyView(currentAccount)) + controller.currentAccountContainerView(AnyView(currentAccount)) } private var currentAccount: some View { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift index 9786f606..3c57890a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift @@ -10,15 +10,12 @@ import Pachyderm import InstanceFeatures struct HeaderView: View { - @EnvironmentObject private var controller: ComposeController - @EnvironmentObject private var draft: Draft - @EnvironmentObject private var instanceFeatures: InstanceFeatures - - private var charsRemaining: Int { controller.charactersRemaining } + let currentAccount: (any AccountProtocol)? + let charsRemaining: Int var body: some View { HStack(alignment: .top) { - CurrentAccountView(account: controller.currentAccount) + CurrentAccountView(account: currentAccount) .accessibilitySortPriority(1) Spacer() diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift index 10e4b198..23e66346 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift @@ -9,17 +9,17 @@ import SwiftUI struct PollOptionView: View { @EnvironmentObject private var controller: PollController - @EnvironmentObject private var poll: Draft.Poll - @ObservedObject private var option: Draft.Poll.Option + @EnvironmentObject private var poll: Poll + @ObservedObject private var option: PollOption let remove: () -> Void - init(option: Draft.Poll.Option, remove: @escaping () -> Void) { + init(option: PollOption, remove: @escaping () -> Void) { self.option = option self.remove = remove } private var optionIndex: Int { - poll.options.firstIndex(where: { $0.id == option.id }) ?? 0 + poll.options.index(of: option) } var body: some View { diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift index a08d2abd..d7d7a673 100644 --- a/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift @@ -28,7 +28,7 @@ public struct AvatarImageView: View { .resizable() .frame(width: size, height: size) .cornerRadius(style.cornerRadiusFraction * size) - .task { + .task { @MainActor in image = nil if let url { image = await fetchAvatar(url) diff --git a/ShareExtension/ShareMastodonContext.swift b/ShareExtension/ShareMastodonContext.swift index a4adbc7b..0a99e105 100644 --- a/ShareExtension/ShareMastodonContext.swift +++ b/ShareExtension/ShareMastodonContext.swift @@ -50,7 +50,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { } } - // MARK: CompmoseMastodonContext + // MARK: ComposeMastodonContext func run(_ request: Request) async throws -> (Result, Pagination?) { return try await withCheckedThrowingContinuation({ continuation in diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index e93625ac..4bbecdcb 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -17,8 +17,6 @@ class ShareViewController: UIViewController { private var state: State = .loading - private var draftsPresenterCancellable: AnyCancellable? - required init?(coder: NSCoder) { super.init(coder: coder) } @@ -29,11 +27,6 @@ class ShareViewController: UIViewController { view.tintColor = Preferences.shared.accentColor.color if let account = UserAccountsManager.shared.getMostRecentAccount() { - NSFileCoordinator.addFilePresenter(DraftsManager.shared) - draftsPresenterCancellable = AnyCancellable({ - NSFileCoordinator.removeFilePresenter(DraftsManager.shared) - }) - Task { @MainActor in let draft = await createDraft(account: account) state = .ok @@ -58,13 +51,9 @@ class ShareViewController: UIViewController { } private func createDraft(account: UserAccountInfo) async -> Draft { - await withCheckedContinuation({ continuation in - DraftsManager.shared.load { _ in - continuation.resume() - } - }) let (text, attachments) = await getDraftConfigurationFromExtensionContext() - let draft = Draft( + + let draft = DraftsPersistentContainer.shared.createDraft( accountID: account.id, text: text, contentWarning: "", @@ -72,8 +61,12 @@ class ShareViewController: UIViewController { visibility: Preferences.shared.defaultPostVisibility, localOnly: false ) - draft.attachments = attachments - DraftsManager.shared.add(draft) + + for attachment in attachments { + DraftsPersistentContainer.shared.viewContext.insert(attachment) + } + draft.draftAttachments = attachments + return draft } diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 294202ee..059b5392 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -496,7 +496,7 @@ class MastodonController: ObservableObject { } acctsToMention = acctsToMention.uniques() - let draft = Draft( + return DraftsPersistentContainer.shared.createDraft( accountID: accountInfo!.id, text: text ?? acctsToMention.map { "@\($0) " }.joined(), contentWarning: contentWarning, @@ -504,8 +504,6 @@ class MastodonController: ObservableObject { visibility: visibility, localOnly: localOnly ) - DraftsManager.shared.add(draft) - return draft } } diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 061fa4f5..b1340c4f 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -22,8 +22,6 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - private var draftsFileCoordinatorManager: DraftsManagerFileCoordinatorManager! - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { configureSentry() swizzleStatusBar() @@ -64,13 +62,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DispatchQueue.global(qos: .userInitiated).async { let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - if FileManager.default.fileExists(atPath: oldDraftsFile.path) { - if case .failure(let error) = DraftsManager.shared.migrate(from: oldDraftsFile) { - SentrySDK.capture(error: error) + let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist") + for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) { + DraftsPersistentContainer.shared.migrate(from: url) { + if case .failure(let error) = $0 { + SentrySDK.capture(error: error) + } } } - - self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager() } return true @@ -214,25 +213,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - -private class DraftsManagerFileCoordinatorManager { - init() { - NSFileCoordinator.addFilePresenter(DraftsManager.shared) - - NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) - } - - deinit { - NSFileCoordinator.removeFilePresenter(DraftsManager.shared) - } - - @objc private func didEnterBackground() { - NSFileCoordinator.removeFilePresenter(DraftsManager.shared) - } - - @objc private func willEnterForeground() { - NSFileCoordinator.addFilePresenter(DraftsManager.shared) - DraftsManager.shared.load() - } -} diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 8ad25119..47ee01e4 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -77,7 +77,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } func sceneWillResignActive(_ scene: UIScene) { - DraftsManager.shared.save() + DraftsPersistentContainer.shared.save() if let window = window, let nav = window.rootViewController as? UINavigationController, diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index a7a2312e..6d8af805 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -88,7 +88,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). Preferences.save() - DraftsManager.shared.save() + DraftsPersistentContainer.shared.save() } func sceneDidBecomeActive(_ scene: UIScene) { @@ -101,7 +101,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // This may occur due to temporary interruptions (ex. an incoming phone call). Preferences.save() - DraftsManager.shared.save() + DraftsPersistentContainer.shared.save() } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index abcd476d..8713fac8 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -33,18 +33,13 @@ class ComposeHostingController: UIHostingController Void)? init(draft: Draft?, mastodonController: MastodonController) { -// self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager() - let draft = draft ?? mastodonController.createDraft() - DraftsManager.shared.load() { _ in - DraftsManager.shared.add(draft) - } self.controller = ComposeController( draft: draft, config: ComposeUIConfig(), mastodonController: mastodonController, - fetchAvatar: { await ImageCache.avatars.get($0).1 }, + fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 }, fetchStatus: { mastodonController.persistentContainer.status(for: $0) }, displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) }, replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }, @@ -58,7 +53,7 @@ class ComposeHostingController: UIHostingController Draft? { @@ -111,7 +111,7 @@ class UserActivityManager { let uuid = UUID(uuidString: idStr) else { return nil } - return DraftsManager.shared.getBy(id: uuid) + return DraftsPersistentContainer.shared.getDraft(id: uuid) } // MARK: - Check Notifications