Compare commits

..

3 Commits

31 changed files with 998 additions and 510 deletions

View File

@ -33,7 +33,7 @@ class PostService: ObservableObject {
} }
// save before posting, so if a crash occurs during network request, the status won't be lost // save before posting, so if a crash occurs during network request, the status won't be lost
DraftsManager.save() DraftsPersistentContainer.shared.save()
let uploadedAttachments = try await uploadAttachments() let uploadedAttachments = try await uploadAttachments()
@ -49,7 +49,7 @@ class PostService: ObservableObject {
spoilerText: contentWarning, spoilerText: contentWarning,
visibility: draft.visibility, visibility: draft.visibility,
language: nil, language: nil,
pollOptions: draft.poll?.options.map(\.text), pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple, pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
@ -58,17 +58,18 @@ class PostService: ObservableObject {
let (_, _) = try await mastodonController.run(request) let (_, _) = try await mastodonController.run(request)
currentStep += 1 currentStep += 1
DraftsManager.shared.remove(self.draft) DraftsPersistentContainer.shared.viewContext.delete(self.draft)
DraftsManager.save() DraftsPersistentContainer.shared.save()
} catch let error as Client.Error { } catch let error as Client.Error {
throw Error.posting(error) throw Error.posting(error)
} }
} }
@MainActor
private func uploadAttachments() async throws -> [Attachment] { private func uploadAttachments() async throws -> [Attachment] {
var attachments: [Attachment] = [] var attachments: [Attachment] = []
attachments.reserveCapacity(draft.attachments.count) attachments.reserveCapacity(draft.attachments.count)
for (index, attachment) in draft.attachments.enumerated() { for (index, attachment) in draft.draftAttachments.enumerated() {
let data: Data let data: Data
let utType: UTType let utType: UTType
do { do {
@ -90,7 +91,7 @@ class PostService: ObservableObject {
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) { private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
attachment.data.getData(features: mastodonController.instanceFeatures) { result in attachment.getData(features: mastodonController.instanceFeatures) { result in
switch result { switch result {
case let .success(res): case let .success(res):
continuation.resume(returning: res) continuation.resume(returning: res)

View File

@ -27,7 +27,7 @@ class AttachmentRowController: ViewController {
private func removeAttachment() { private func removeAttachment() {
withAnimation { withAnimation {
parent.draft.attachments.removeAll(where: { $0.id == attachment.id }) parent.draft.attachments.remove(attachment)
} }
} }
@ -36,7 +36,7 @@ class AttachmentRowController: ViewController {
return return
} }
parent.config.presentDrawing?(drawing) { newDrawing in parent.config.presentDrawing?(drawing) { newDrawing in
self.attachment.data = .drawing(newDrawing) self.attachment.drawing = newDrawing
} }
} }
@ -44,7 +44,7 @@ class AttachmentRowController: ViewController {
descriptionMode = .recognizingText descriptionMode = .recognizingText
DispatchQueue.global(qos: .userInitiated).async { 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 let data: Data
switch result { switch result {
case .success((let d, _)): case .success((let d, _)):
@ -103,11 +103,11 @@ class AttachmentRowController: ViewController {
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
if case .drawing(_) = attachment.data { if attachment.drawingData != nil {
Button(action: controller.editDrawing) { Button(action: controller.editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw") Label("Edit Drawing", systemImage: "hand.draw")
} }
} else if attachment.data.type == .image { } else if attachment.type == .image {
Button(action: controller.recognizeText) { Button(action: controller.recognizeText) {
Label("Recognize Text", systemImage: "doc.text.viewfinder") Label("Recognize Text", systemImage: "doc.text.viewfinder")
} }

View File

@ -20,7 +20,7 @@ class AttachmentsListController: ViewController {
private var requiresAttachmentDescriptions: Bool { private var requiresAttachmentDescriptions: Bool {
if parent.config.requireAttachmentDescriptions { if parent.config.requireAttachmentDescriptions {
return draft.attachments.allSatisfy { return draft.draftAttachments.allSatisfy {
!$0.attachmentDescription.isEmpty !$0.attachmentDescription.isEmpty
} }
} else { } else {
@ -31,8 +31,8 @@ class AttachmentsListController: ViewController {
private var validAttachmentCombination: Bool { private var validAttachmentCombination: Bool {
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
return true return true
} else if draft.attachments.contains(where: { $0.data.type == .video }) && } else if draft.attachments.count > 1,
draft.attachments.count > 1 { draft.draftAttachments.contains(where: { $0.type == .video }) {
return false return false
} else if draft.attachments.count > 4 { } else if draft.attachments.count > 4 {
return false return false
@ -44,9 +44,9 @@ class AttachmentsListController: ViewController {
self.parent = parent self.parent = parent
} }
private var canAddAttachment: Bool { var canAddAttachment: Bool {
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { 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 { } else {
return true return true
} }
@ -56,7 +56,7 @@ class AttachmentsListController: ViewController {
if parent.mastodonController.instanceFeatures.pollsAndAttachments { if parent.mastodonController.instanceFeatures.pollsAndAttachments {
return true return true
} else { } 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) { 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) { private func deleteAttachments(at indices: IndexSet) {
draft.attachments.remove(atOffsets: indices) draft.attachments.removeObjects(at: indices)
} }
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
@ -78,7 +83,9 @@ class AttachmentsListController: ViewController {
guard let attachment = object as? DraftAttachment else { return } guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
guard self.canAddAttachment else { return } 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() { private func addDrawing() {
parent.config.presentDrawing?(PKDrawing()) { drawing in 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) UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
withAnimation { 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 { 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) }) ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
.onDrag {
NSItemProvider(object: attachment)
}
} }
.onMove(perform: controller.moveAttachments) .onMove(perform: controller.moveAttachments)
.onDelete(perform: controller.deleteAttachments) .onDelete(perform: controller.deleteAttachments)

View File

@ -23,7 +23,7 @@ public final class ComposeController: ViewController {
let fetchAvatar: AvatarImageView.FetchAvatar let fetchAvatar: AvatarImageView.FetchAvatar
let fetchStatus: FetchStatus let fetchStatus: FetchStatus
let displayNameLabel: DisplayNameLabel let displayNameLabel: DisplayNameLabel
let currentAccountContainerview: CurrentAccountContainerView let currentAccountContainerView: CurrentAccountContainerView
let replyContentView: ReplyContentView let replyContentView: ReplyContentView
let emojiImageView: EmojiImageView let emojiImageView: EmojiImageView
@ -63,7 +63,7 @@ public final class ComposeController: ViewController {
} }
private var isPollValid: Bool { 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( public init(
@ -83,7 +83,7 @@ public final class ComposeController: ViewController {
self.fetchAvatar = fetchAvatar self.fetchAvatar = fetchAvatar
self.fetchStatus = fetchStatus self.fetchStatus = fetchStatus
self.displayNameLabel = displayNameLabel self.displayNameLabel = displayNameLabel
self.currentAccountContainerview = currentAccountContainerView self.currentAccountContainerView = currentAccountContainerView
self.replyContentView = replyContentView self.replyContentView = replyContentView
self.emojiImageView = emojiImageView self.emojiImageView = emojiImageView
@ -94,6 +94,7 @@ public final class ComposeController: ViewController {
public var view: some View { public var view: some View {
ComposeView(poster: poster) ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft) .environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures) .environmentObject(mastodonController.instanceFeatures)
} }
@ -103,7 +104,7 @@ public final class ComposeController: ViewController {
return false return false
} }
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { 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 // if providers are videos, this technically allows invalid video/image combinations
return itemProviders.count + draft.attachments.count <= 4 return itemProviders.count + draft.attachments.count <= 4
} else { } else {
@ -119,7 +120,10 @@ public final class ComposeController: ViewController {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return } guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async { 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 { if draft.hasContent {
isShowingSaveDraftSheet = true isShowingSaveDraftSheet = true
} else { } else {
DraftsManager.shared.remove(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
config.dismiss(.cancel) config.dismiss(.cancel)
} }
} }
@ -142,7 +146,7 @@ public final class ComposeController: ViewController {
@MainActor @MainActor
func cancel(deleteDraft: Bool) { func cancel(deleteDraft: Bool) {
if deleteDraft { if deleteDraft {
DraftsManager.shared.remove(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
} }
config.dismiss(.cancel) config.dismiss(.cancel)
} }
@ -185,20 +189,20 @@ public final class ComposeController: ViewController {
isShowingDraftsList = true isShowingDraftsList = true
} }
func selectDraft(_ draft: Draft) { func selectDraft(_ newDraft: Draft) {
if !self.draft.hasContent { if !self.draft.hasContent {
DraftsManager.shared.remove(self.draft) DraftsPersistentContainer.shared.viewContext.delete(self.draft)
} }
DraftsManager.save() DraftsPersistentContainer.shared.save()
self.draft = draft self.draft = newDraft
} }
func onDisappear() { func onDisappear() {
if !draft.hasContent { if !draft.hasContent {
DraftsManager.shared.remove(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
} }
DraftsManager.save() DraftsPersistentContainer.shared.save()
} }
func toggleContentWarning() { func toggleContentWarning() {
@ -296,7 +300,7 @@ public final class ComposeController: ViewController {
.listRowBackground(config.backgroundColor) .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)) .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor) .listRowBackground(config.backgroundColor)

View File

@ -43,12 +43,12 @@ class DraftsController: ViewController {
} }
func deleteDraft(_ draft: Draft) { func deleteDraft(_ draft: Draft) {
DraftsManager.shared.remove(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
} }
func closeDrafts() { func closeDrafts() {
isPresented = false isPresented = false
DraftsManager.save() DraftsPersistentContainer.shared.save()
} }
struct DraftsRepresentable: UIViewControllerRepresentable { struct DraftsRepresentable: UIViewControllerRepresentable {
@ -65,18 +65,12 @@ class DraftsController: ViewController {
struct DraftsView: View { struct DraftsView: View {
@EnvironmentObject private var controller: DraftsController @EnvironmentObject private var controller: DraftsController
@EnvironmentObject private var currentDraft: Draft @EnvironmentObject private var currentDraft: Draft
@ObservedObject private var draftsManager = DraftsManager.shared @FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
private var visibleDrafts: [Draft] {
draftsManager.sorted.filter {
$0.accountID == controller.parent.mastodonController.accountInfo!.id && $0.id != currentDraft.id
}
}
var body: some View { var body: some View {
NavigationView { NavigationView {
List { List {
ForEach(visibleDrafts) { draft in ForEach(drafts) { draft in
Button(action: { controller.maybeSelectDraft(draft) }) { Button(action: { controller.maybeSelectDraft(draft) }) {
DraftRow(draft: draft) DraftRow(draft: draft)
} }
@ -90,7 +84,7 @@ class DraftsController: ViewController {
}) })
} }
.onDelete { indices in .onDelete { indices in
indices.map { visibleDrafts[$0] }.forEach(controller.deleteDraft) indices.map { drafts[$0] }.forEach(controller.deleteDraft)
} }
} }
.listStyle(.plain) .listStyle(.plain)
@ -110,6 +104,9 @@ class DraftsController: ViewController {
} message: { _ in } message: { _ in
Text("The selected draft is a reply to a different post, do you wish to use it?") 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 { private var cancelButton: some View {
@ -136,7 +133,7 @@ private struct DraftRow: View {
.font(.body) .font(.body)
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(draft.attachments) { attachment in ForEach(draft.draftAttachments) { attachment in
AttachmentThumbnailView(attachment: attachment, fullSize: false) AttachmentThumbnailView(attachment: attachment, fullSize: false)
.frame(width: 50, height: 50) .frame(width: 50, height: 50)
.cornerRadius(5) .cornerRadius(5)

View File

@ -12,11 +12,11 @@ class PollController: ViewController {
unowned let parent: ComposeController unowned let parent: ComposeController
var draft: Draft { parent.draft } var draft: Draft { parent.draft }
let poll: Draft.Poll let poll: Poll
@Published var duration: Duration @Published var duration: Duration
init(parent: ComposeController, poll: Draft.Poll) { init(parent: ComposeController, poll: Poll) {
self.parent = parent self.parent = parent
self.poll = poll self.poll = poll
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
@ -34,11 +34,11 @@ class PollController: ViewController {
} }
private func moveOptions(indices: IndexSet, newIndex: Int) { 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) { private func removeOption(_ option: PollOption) {
poll.options.removeAll(where: { $0.id == option.id }) poll.options.remove(option)
} }
private var canAddOption: Bool { private var canAddOption: Bool {
@ -50,12 +50,14 @@ class PollController: ViewController {
} }
private func addOption() { private func addOption() {
poll.options.append(.init("")) let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
option.poll = poll
poll.options.add(option)
} }
struct PollView: View { struct PollView: View {
@EnvironmentObject private var controller: PollController @EnvironmentObject private var controller: PollController
@EnvironmentObject private var poll: Draft.Poll @EnvironmentObject private var poll: Poll
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
var body: some View { var body: some View {
@ -79,7 +81,7 @@ class PollController: ViewController {
} }
List { List {
ForEach(poll.options) { option in ForEach($poll.pollOptions) { $option in
PollOptionView(option: option, remove: { controller.removeOption(option) }) PollOptionView(option: option, remove: { controller.removeOption(option) })
.frame(height: 36) .frame(height: 36)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))

View File

@ -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<Draft> {
return NSFetchRequest<Draft>(entityName: "Draft")
}
@nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest<Draft> {
let req = NSFetchRequest<Draft>(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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
<attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="text" attributeType="String" defaultValueString=""/>
<attribute name="visibilityStr" optional="YES" attributeType="String"/>
<relationship name="attachments" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="DraftAttachment" inverseName="draft" inverseEntity="DraftAttachment"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="draft" inverseEntity="Poll"/>
</entity>
<entity name="DraftAttachment" representedClassName="ComposeUI.DraftAttachment" syncable="YES">
<attribute name="assetID" optional="YES" attributeType="String"/>
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
<attribute name="fileType" optional="YES" attributeType="String"/>
<attribute name="fileURL" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="attachments" inverseEntity="Draft"/>
</entity>
<entity name="Poll" representedClassName="ComposeUI.Poll" syncable="YES">
<attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="poll" inverseEntity="Draft"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
</entity>
<entity name="PollOption" representedClassName="ComposeUI.PollOption" syncable="YES">
<attribute name="text" attributeType="String" defaultValueString=""/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
</entity>
<entity name="TestEntity" representedClassName="TestEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
</model>

View File

@ -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
}
}
}

View File

@ -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 }
}
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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
}
}
}

View File

@ -1,114 +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")
public class DraftsManager: Codable, ObservableObject {
public private(set) static var shared: DraftsManager = load()
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
private static let saveQueue = DispatchQueue(label: "DraftsManager", qos: .utility)
public static func save() {
saveQueue.async {
let encoder = PropertyListEncoder()
do {
let data = try encoder.encode(shared)
try data.write(to: archiveURL, options: .noFileProtection)
} catch {
logger.error("Save failed: \(String(describing: error))")
}
}
}
static func load() -> DraftsManager {
let decoder = PropertyListDecoder()
do {
let data = try Data(contentsOf: archiveURL)
let draftsManager = try decoder.decode(DraftsManager.self, from: data)
return draftsManager
} catch {
logger.error("Load failed: \(String(describing: error))")
return DraftsManager()
}
}
public static func migrate(from url: URL) -> Result<Void, any Error> {
do {
try? FileManager.default.removeItem(at: archiveURL)
try FileManager.default.moveItem(at: url, to: archiveURL)
} catch {
return .failure(error)
}
shared = load()
return .success(())
}
private init() {}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let dict = try? container.decode([UUID: SafeDraft].self, forKey: .drafts) {
self.drafts = dict.compactMapValues { $0.draft }
} else if let array = try? container.decode([SafeDraft].self, forKey: .drafts) {
self.drafts = array.reduce(into: [:], { partialResult, safeDraft in
if let draft = safeDraft.draft {
partialResult[draft.id] = draft
}
})
} else {
throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts")
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(drafts, forKey: .drafts)
}
@Published private var drafts: [UUID: Draft] = [:]
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]
}
enum CodingKeys: String, 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)
}
}
}

View File

@ -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<TestEntity>
var body: some View {
List {
ForEach(results) { result in
Text(result.id?.uuidString ?? "<nil>")
}
}
}
}

View File

@ -48,9 +48,10 @@ struct AttachmentThumbnailView: View {
private func loadImage() { private func loadImage() {
switch attachment.data { switch attachment.data {
case let .image(originalData, originalType: _): case .asset(let id):
self.image = UIImage(data: originalData) guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
case let .asset(asset): return
}
let size: CGSize let size: CGSize
if fullSize { if fullSize {
size = PHImageManagerMaximumSize size = PHImageManagerMaximumSize
@ -77,18 +78,35 @@ struct AttachmentThumbnailView: View {
} }
} }
} }
case let .video(url):
case let .drawing(drawing):
image = drawing.imageInLightMode(from: drawing.bounds)
imageContentMode = .fit
imageBackgroundColor = .white
case .file(let url, let type):
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset) let imageGenerator = AVAssetImageGenerator(asset: asset)
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage) self.image = UIImage(cgImage: cgImage)
} }
case let .drawing(drawing): } else if let data = try? Data(contentsOf: url) {
image = drawing.imageInLightMode(from: drawing.bounds) if type == .gif {
imageContentMode = .fit
imageBackgroundColor = .white
case let .gif(data):
self.gifData = data 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
}
}
}
}
} }
} }
} }

View File

@ -14,7 +14,7 @@ struct CurrentAccountView: View {
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
var body: some View { var body: some View {
controller.currentAccountContainerview(AnyView(currentAccount)) controller.currentAccountContainerView(AnyView(currentAccount))
} }
private var currentAccount: some View { private var currentAccount: some View {

View File

@ -10,15 +10,12 @@ import Pachyderm
import InstanceFeatures import InstanceFeatures
struct HeaderView: View { struct HeaderView: View {
@EnvironmentObject private var controller: ComposeController let currentAccount: (any AccountProtocol)?
@EnvironmentObject private var draft: Draft let charsRemaining: Int
@EnvironmentObject private var instanceFeatures: InstanceFeatures
private var charsRemaining: Int { controller.charactersRemaining }
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
CurrentAccountView(account: controller.currentAccount) CurrentAccountView(account: currentAccount)
.accessibilitySortPriority(1) .accessibilitySortPriority(1)
Spacer() Spacer()

View File

@ -9,17 +9,17 @@ import SwiftUI
struct PollOptionView: View { struct PollOptionView: View {
@EnvironmentObject private var controller: PollController @EnvironmentObject private var controller: PollController
@EnvironmentObject private var poll: Draft.Poll @EnvironmentObject private var poll: Poll
@ObservedObject private var option: Draft.Poll.Option @ObservedObject private var option: PollOption
let remove: () -> Void let remove: () -> Void
init(option: Draft.Poll.Option, remove: @escaping () -> Void) { init(option: PollOption, remove: @escaping () -> Void) {
self.option = option self.option = option
self.remove = remove self.remove = remove
} }
private var optionIndex: Int { private var optionIndex: Int {
poll.options.firstIndex(where: { $0.id == option.id }) ?? 0 poll.options.index(of: option)
} }
var body: some View { var body: some View {

View File

@ -28,7 +28,7 @@ public struct AvatarImageView: View {
.resizable() .resizable()
.frame(width: size, height: size) .frame(width: size, height: size)
.cornerRadius(style.cornerRadiusFraction * size) .cornerRadius(style.cornerRadiusFraction * size)
.task { .task { @MainActor in
image = nil image = nil
if let url { if let url {
image = await fetchAvatar(url) image = await fetchAvatar(url)

View File

@ -19,7 +19,8 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
let image = UIImage(data: data) else { let image = UIImage(data: data) else {
return nil return nil
} }
return await image.byPreparingThumbnail(ofSize: CGSize(width: 50, height: 50)) ?? image let size = 50 * UIScreen.main.scale
return await image.byPreparingThumbnail(ofSize: CGSize(width: size, height: size)) ?? image
} }
private let controller: ComposeController private let controller: ComposeController

View File

@ -50,7 +50,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
} }
} }
// MARK: CompmoseMastodonContext // MARK: ComposeMastodonContext
func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) { func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation({ continuation in return try await withCheckedThrowingContinuation({ continuation in

View File

@ -11,6 +11,7 @@ import UserAccounts
import ComposeUI import ComposeUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import TuskerPreferences import TuskerPreferences
import Combine
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
@ -18,7 +19,6 @@ class ShareViewController: UIViewController {
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
super.init(coder: coder) super.init(coder: coder)
} }
override func viewDidLoad() { override func viewDidLoad() {
@ -52,7 +52,8 @@ class ShareViewController: UIViewController {
private func createDraft(account: UserAccountInfo) async -> Draft { private func createDraft(account: UserAccountInfo) async -> Draft {
let (text, attachments) = await getDraftConfigurationFromExtensionContext() let (text, attachments) = await getDraftConfigurationFromExtensionContext()
let draft = Draft(
let draft = DraftsPersistentContainer.shared.createDraft(
accountID: account.id, accountID: account.id,
text: text, text: text,
contentWarning: "", contentWarning: "",
@ -60,8 +61,12 @@ class ShareViewController: UIViewController {
visibility: Preferences.shared.defaultPostVisibility, visibility: Preferences.shared.defaultPostVisibility,
localOnly: false localOnly: false
) )
draft.attachments = attachments
DraftsManager.shared.add(draft) for attachment in attachments {
DraftsPersistentContainer.shared.viewContext.insert(attachment)
}
draft.draftAttachments = attachments
return draft return draft
} }

View File

@ -496,7 +496,7 @@ class MastodonController: ObservableObject {
} }
acctsToMention = acctsToMention.uniques() acctsToMention = acctsToMention.uniques()
let draft = Draft( return DraftsPersistentContainer.shared.createDraft(
accountID: accountInfo!.id, accountID: accountInfo!.id,
text: text ?? acctsToMention.map { "@\($0) " }.joined(), text: text ?? acctsToMention.map { "@\($0) " }.joined(),
contentWarning: contentWarning, contentWarning: contentWarning,
@ -504,8 +504,6 @@ class MastodonController: ObservableObject {
visibility: visibility, visibility: visibility,
localOnly: localOnly localOnly: localOnly
) )
DraftsManager.shared.add(draft)
return draft
} }
} }

View File

@ -22,7 +22,6 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configureSentry() configureSentry()
swizzleStatusBar() swizzleStatusBar()
@ -63,12 +62,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
if FileManager.default.fileExists(atPath: oldDraftsFile.path) { let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
if case .failure(let error) = DraftsManager.migrate(from: oldDraftsFile) { 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) SentrySDK.capture(error: error)
} }
} }
} }
}
return true return true
} }

View File

@ -77,7 +77,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
} }
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
DraftsManager.save() DraftsPersistentContainer.shared.save()
if let window = window, if let window = window,
let nav = window.rootViewController as? UINavigationController, let nav = window.rootViewController as? UINavigationController,

View File

@ -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). // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
Preferences.save() Preferences.save()
DraftsManager.save() DraftsPersistentContainer.shared.save()
} }
func sceneDidBecomeActive(_ scene: UIScene) { 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). // This may occur due to temporary interruptions (ex. an incoming phone call).
Preferences.save() Preferences.save()
DraftsManager.save() DraftsPersistentContainer.shared.save()
} }
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {

View File

@ -6,6 +6,7 @@
// Copyright © 2023 Shadowfacts. All rights reserved. // Copyright © 2023 Shadowfacts. All rights reserved.
// //
import UIKit
import SwiftUI import SwiftUI
import ComposeUI import ComposeUI
import Combine import Combine
@ -27,18 +28,18 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
let controller: ComposeController let controller: ComposeController
let mastodonController: MastodonController let mastodonController: MastodonController
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)? private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
private var drawingCompletion: ((PKDrawing) -> Void)? private var drawingCompletion: ((PKDrawing) -> Void)?
init(draft: Draft?, mastodonController: MastodonController) { init(draft: Draft?, mastodonController: MastodonController) {
let draft = draft ?? mastodonController.createDraft() let draft = draft ?? mastodonController.createDraft()
DraftsManager.shared.add(draft)
self.controller = ComposeController( self.controller = ComposeController(
draft: draft, draft: draft,
config: ComposeUIConfig(), config: ComposeUIConfig(),
mastodonController: mastodonController, mastodonController: mastodonController,
fetchAvatar: { await ImageCache.avatars.get($0).1 }, fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
fetchStatus: { mastodonController.persistentContainer.status(for: $0) }, fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) }, displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) },
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }, replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
@ -52,7 +53,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
self.updateConfig() self.updateConfig()
pasteConfiguration = UIPasteConfiguration(forAccepting: ComposeUI.DraftAttachment.self) pasteConfiguration = UIPasteConfiguration(forAccepting: DraftAttachment.self)
NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil)
} }

View File

@ -103,7 +103,7 @@ class UserActivityManager {
let uuid = UUID(uuidString: idStr) else { let uuid = UUID(uuidString: idStr) else {
return nil return nil
} }
return DraftsManager.shared.getBy(id: uuid) return DraftsPersistentContainer.shared.getDraft(id: uuid)
} }
static func getDuckedDraft(from activity: NSUserActivity) -> Draft? { static func getDuckedDraft(from activity: NSUserActivity) -> Draft? {
@ -111,7 +111,7 @@ class UserActivityManager {
let uuid = UUID(uuidString: idStr) else { let uuid = UUID(uuidString: idStr) else {
return nil return nil
} }
return DraftsManager.shared.getBy(id: uuid) return DraftsPersistentContainer.shared.getDraft(id: uuid)
} }
// MARK: - Check Notifications // MARK: - Check Notifications