Use CoreData for drafts store
This commit is contained in:
parent
2874e4bfd3
commit
f51f3c8a94
|
@ -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.shared.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.shared.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)
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.shared.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.shared.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)
|
||||||
|
|
|
@ -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.shared.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)
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Void, any Error> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -17,8 +17,6 @@ class ShareViewController: UIViewController {
|
||||||
|
|
||||||
private var state: State = .loading
|
private var state: State = .loading
|
||||||
|
|
||||||
private var draftsPresenterCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
}
|
}
|
||||||
|
@ -29,11 +27,6 @@ class ShareViewController: UIViewController {
|
||||||
view.tintColor = Preferences.shared.accentColor.color
|
view.tintColor = Preferences.shared.accentColor.color
|
||||||
|
|
||||||
if let account = UserAccountsManager.shared.getMostRecentAccount() {
|
if let account = UserAccountsManager.shared.getMostRecentAccount() {
|
||||||
NSFileCoordinator.addFilePresenter(DraftsManager.shared)
|
|
||||||
draftsPresenterCancellable = AnyCancellable({
|
|
||||||
NSFileCoordinator.removeFilePresenter(DraftsManager.shared)
|
|
||||||
})
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let draft = await createDraft(account: account)
|
let draft = await createDraft(account: account)
|
||||||
state = .ok
|
state = .ok
|
||||||
|
@ -58,13 +51,9 @@ class ShareViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDraft(account: UserAccountInfo) async -> Draft {
|
private func createDraft(account: UserAccountInfo) async -> Draft {
|
||||||
await withCheckedContinuation({ continuation in
|
|
||||||
DraftsManager.shared.load { _ in
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
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: "",
|
||||||
|
@ -72,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,6 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
private var draftsFileCoordinatorManager: DraftsManagerFileCoordinatorManager!
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
configureSentry()
|
configureSentry()
|
||||||
swizzleStatusBar()
|
swizzleStatusBar()
|
||||||
|
@ -64,13 +62,14 @@ 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.shared.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -77,7 +77,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillResignActive(_ scene: UIScene) {
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
DraftsManager.shared.save()
|
DraftsPersistentContainer.shared.save()
|
||||||
|
|
||||||
if let window = window,
|
if let window = window,
|
||||||
let nav = window.rootViewController as? UINavigationController,
|
let nav = window.rootViewController as? UINavigationController,
|
||||||
|
|
|
@ -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.shared.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.shared.save()
|
DraftsPersistentContainer.shared.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
||||||
|
|
|
@ -33,18 +33,13 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
private var drawingCompletion: ((PKDrawing) -> Void)?
|
private var drawingCompletion: ((PKDrawing) -> Void)?
|
||||||
|
|
||||||
init(draft: Draft?, mastodonController: MastodonController) {
|
init(draft: Draft?, mastodonController: MastodonController) {
|
||||||
// self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager()
|
|
||||||
|
|
||||||
let draft = draft ?? mastodonController.createDraft()
|
let draft = draft ?? mastodonController.createDraft()
|
||||||
DraftsManager.shared.load() { _ in
|
|
||||||
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)) },
|
||||||
|
@ -58,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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue