Compare commits
10 Commits
074a296a68
...
205056f636
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 205056f636 | |
Shadowfacts | 40197e04cf | |
Shadowfacts | 2249e5a315 | |
Shadowfacts | bff1ea8b9d | |
Shadowfacts | b614226871 | |
Shadowfacts | f51f3c8a94 | |
Shadowfacts | 2874e4bfd3 | |
Shadowfacts | 74a157d26c | |
Shadowfacts | 3d3fc3f515 | |
Shadowfacts | 6c371f868f |
|
@ -10,6 +10,7 @@ import Foundation
|
|||
import Pachyderm
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
class PostService: ObservableObject {
|
||||
private let mastodonController: ComposeMastodonContext
|
||||
private let config: ComposeUIConfig
|
||||
|
@ -26,14 +27,13 @@ class PostService: ObservableObject {
|
|||
self.totalSteps = 2 + (draft.attachments.count * 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func post() async throws {
|
||||
guard draft.hasContent else {
|
||||
return
|
||||
}
|
||||
|
||||
// save before posting, so if a crash occurs during network request, the status won't be lost
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.save()
|
||||
|
||||
let uploadedAttachments = try await uploadAttachments()
|
||||
|
||||
|
@ -49,7 +49,7 @@ class PostService: ObservableObject {
|
|||
spoilerText: contentWarning,
|
||||
visibility: draft.visibility,
|
||||
language: nil,
|
||||
pollOptions: draft.poll?.options.map(\.text),
|
||||
pollOptions: draft.poll?.pollOptions.map(\.text),
|
||||
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||
pollMultiple: draft.poll?.multiple,
|
||||
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
|
||||
|
@ -57,9 +57,6 @@ class PostService: ObservableObject {
|
|||
do {
|
||||
let (_, _) = try await mastodonController.run(request)
|
||||
currentStep += 1
|
||||
|
||||
DraftsManager.shared.remove(self.draft)
|
||||
DraftsManager.save()
|
||||
} catch let error as Client.Error {
|
||||
throw Error.posting(error)
|
||||
}
|
||||
|
@ -68,7 +65,7 @@ class PostService: ObservableObject {
|
|||
private func uploadAttachments() async throws -> [Attachment] {
|
||||
var attachments: [Attachment] = []
|
||||
attachments.reserveCapacity(draft.attachments.count)
|
||||
for (index, attachment) in draft.attachments.enumerated() {
|
||||
for (index, attachment) in draft.draftAttachments.enumerated() {
|
||||
let data: Data
|
||||
let utType: UTType
|
||||
do {
|
||||
|
@ -90,7 +87,7 @@ class PostService: ObservableObject {
|
|||
|
||||
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
attachment.data.getData(features: mastodonController.instanceFeatures) { result in
|
||||
attachment.getData(features: mastodonController.instanceFeatures) { result in
|
||||
switch result {
|
||||
case let .success(res):
|
||||
continuation.resume(returning: res)
|
||||
|
|
|
@ -12,16 +12,24 @@ import PencilKit
|
|||
import TuskerComponents
|
||||
|
||||
public struct ComposeUIConfig {
|
||||
// Config
|
||||
public var allowSwitchingDrafts = true
|
||||
public var textSelectionStartsAtBeginning = false
|
||||
|
||||
// Style
|
||||
public var backgroundColor = Color(uiColor: .systemBackground)
|
||||
public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground)
|
||||
public var groupedCellBackgroundColor = Color(uiColor: .systemBackground)
|
||||
public var fillColor = Color(uiColor: .systemFill)
|
||||
public var avatarStyle = AvatarImageView.Style.roundRect
|
||||
|
||||
// Preferences
|
||||
public var useTwitterKeyboard = false
|
||||
public var contentType = StatusContentType.plain
|
||||
public var automaticallySaveDrafts = false
|
||||
public var requireAttachmentDescriptions = false
|
||||
|
||||
// Host callbacks
|
||||
public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
|
||||
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
|
||||
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
|
||||
|
|
|
@ -16,9 +16,26 @@ class AttachmentRowController: ViewController {
|
|||
@Published var descriptionMode: DescriptionMode = .allowEntry
|
||||
@Published var textRecognitionError: Error?
|
||||
|
||||
private var descriptionObservation: NSKeyValueObservation?
|
||||
|
||||
init(parent: ComposeController, attachment: DraftAttachment) {
|
||||
self.parent = parent
|
||||
self.attachment = attachment
|
||||
|
||||
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
||||
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
||||
if attachment.faultingState == 0 {
|
||||
self.updateAttachmentDescriptionState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func updateAttachmentDescriptionState() {
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
parent.attachmentsMissingDescriptions.insert(attachment.id)
|
||||
} else {
|
||||
parent.attachmentsMissingDescriptions.remove(attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
|
@ -27,7 +44,7 @@ class AttachmentRowController: ViewController {
|
|||
|
||||
private func removeAttachment() {
|
||||
withAnimation {
|
||||
parent.draft.attachments.removeAll(where: { $0.id == attachment.id })
|
||||
parent.draft.attachments.remove(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,7 +53,7 @@ class AttachmentRowController: ViewController {
|
|||
return
|
||||
}
|
||||
parent.config.presentDrawing?(drawing) { newDrawing in
|
||||
self.attachment.data = .drawing(newDrawing)
|
||||
self.attachment.drawing = newDrawing
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,7 +61,7 @@ class AttachmentRowController: ViewController {
|
|||
descriptionMode = .recognizingText
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.attachment.data.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||
let data: Data
|
||||
switch result {
|
||||
case .success((let d, _)):
|
||||
|
@ -103,11 +120,11 @@ class AttachmentRowController: ViewController {
|
|||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
if case .drawing(_) = attachment.data {
|
||||
if attachment.drawingData != nil {
|
||||
Button(action: controller.editDrawing) {
|
||||
Label("Edit Drawing", systemImage: "hand.draw")
|
||||
}
|
||||
} else if attachment.data.type == .image {
|
||||
} else if attachment.type == .image {
|
||||
Button(action: controller.recognizeText) {
|
||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||
}
|
||||
|
@ -138,6 +155,7 @@ class AttachmentRowController: ViewController {
|
|||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ class AttachmentsListController: ViewController {
|
|||
|
||||
private var requiresAttachmentDescriptions: Bool {
|
||||
if parent.config.requireAttachmentDescriptions {
|
||||
if draft.attachments.isEmpty {
|
||||
if draft.attachments.count == 0 {
|
||||
return false
|
||||
} else {
|
||||
return !parent.attachmentsMissingDescriptions.isEmpty
|
||||
|
@ -33,8 +33,8 @@ class AttachmentsListController: ViewController {
|
|||
var validAttachmentCombination: Bool {
|
||||
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return true
|
||||
} else if draft.attachments.contains(where: { $0.data.type == .video }) &&
|
||||
draft.attachments.count > 1 {
|
||||
} else if draft.attachments.count > 1,
|
||||
draft.draftAttachments.contains(where: { $0.type == .video }) {
|
||||
return false
|
||||
} else if draft.attachments.count > 4 {
|
||||
return false
|
||||
|
@ -46,9 +46,9 @@ class AttachmentsListController: ViewController {
|
|||
self.parent = parent
|
||||
}
|
||||
|
||||
private var canAddAttachment: Bool {
|
||||
var canAddAttachment: Bool {
|
||||
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil
|
||||
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ class AttachmentsListController: ViewController {
|
|||
if parent.mastodonController.instanceFeatures.pollsAndAttachments {
|
||||
return true
|
||||
} else {
|
||||
return draft.attachments.isEmpty
|
||||
return draft.attachments.count == 0
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -67,11 +67,16 @@ class AttachmentsListController: ViewController {
|
|||
}
|
||||
|
||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
||||
draft.attachments.move(fromOffsets: source, toOffset: destination)
|
||||
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
|
||||
// results in the order switching back to the previous order and then to the correct one
|
||||
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
|
||||
var array = draft.draftAttachments
|
||||
array.move(fromOffsets: source, toOffset: destination)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func deleteAttachments(at indices: IndexSet) {
|
||||
draft.attachments.remove(atOffsets: indices)
|
||||
draft.attachments.removeObjects(at: indices)
|
||||
}
|
||||
|
||||
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
|
||||
|
@ -80,7 +85,9 @@ class AttachmentsListController: ViewController {
|
|||
guard let attachment = object as? DraftAttachment else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard self.canAddAttachment else { return }
|
||||
self.draft.attachments.append(attachment)
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -94,7 +101,10 @@ class AttachmentsListController: ViewController {
|
|||
|
||||
private func addDrawing() {
|
||||
parent.config.presentDrawing?(PKDrawing()) { drawing in
|
||||
self.draft.attachments.append(DraftAttachment(data: .drawing(drawing)))
|
||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||
attachment.drawing = drawing
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -102,7 +112,7 @@ class AttachmentsListController: ViewController {
|
|||
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
withAnimation {
|
||||
draft.poll = draft.poll == nil ? Draft.Poll() : nil
|
||||
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,19 +145,9 @@ class AttachmentsListController: ViewController {
|
|||
}
|
||||
|
||||
private var attachmentsList: some View {
|
||||
ForEach(draft.attachments) { attachment in
|
||||
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
||||
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
.onDrag {
|
||||
NSItemProvider(object: attachment)
|
||||
}
|
||||
.onReceive(attachment.$attachmentDescription.map(\.isEmpty).removeDuplicates()) { isEmpty in
|
||||
if isEmpty {
|
||||
controller.parent.attachmentsMissingDescriptions.insert(attachment.id)
|
||||
} else {
|
||||
controller.parent.attachmentsMissingDescriptions.remove(attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove(perform: controller.moveAttachments)
|
||||
.onDelete(perform: controller.deleteAttachments)
|
||||
|
|
|
@ -13,15 +13,17 @@ import TuskerComponents
|
|||
public final class ComposeController: ViewController {
|
||||
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
||||
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
|
||||
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
|
||||
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
||||
public typealias EmojiImageView = (Emoji) -> AnyView
|
||||
|
||||
@Published public private(set) var draft: Draft
|
||||
@Published public var config: ComposeUIConfig
|
||||
let mastodonController: ComposeMastodonContext
|
||||
@Published public var mastodonController: ComposeMastodonContext
|
||||
let fetchAvatar: AvatarImageView.FetchAvatar
|
||||
let fetchStatus: FetchStatus
|
||||
let displayNameLabel: DisplayNameLabel
|
||||
let currentAccountContainerView: CurrentAccountContainerView
|
||||
let replyContentView: ReplyContentView
|
||||
let emojiImageView: EmojiImageView
|
||||
|
||||
|
@ -43,6 +45,7 @@ public final class ComposeController: ViewController {
|
|||
@Published var isShowingDraftsList = false
|
||||
@Published var poster: PostService?
|
||||
@Published var postError: (any Error)?
|
||||
@Published public private(set) var didPostSuccessfully = false
|
||||
|
||||
var isPosting: Bool {
|
||||
poster != nil
|
||||
|
@ -64,7 +67,7 @@ public final class ComposeController: ViewController {
|
|||
}
|
||||
|
||||
private var isPollValid: Bool {
|
||||
draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }
|
||||
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||
}
|
||||
|
||||
public init(
|
||||
|
@ -74,6 +77,7 @@ public final class ComposeController: ViewController {
|
|||
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
|
||||
fetchStatus: @escaping FetchStatus,
|
||||
displayNameLabel: @escaping DisplayNameLabel,
|
||||
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
|
||||
replyContentView: @escaping ReplyContentView,
|
||||
emojiImageView: @escaping EmojiImageView
|
||||
) {
|
||||
|
@ -83,6 +87,7 @@ public final class ComposeController: ViewController {
|
|||
self.fetchAvatar = fetchAvatar
|
||||
self.fetchStatus = fetchStatus
|
||||
self.displayNameLabel = displayNameLabel
|
||||
self.currentAccountContainerView = currentAccountContainerView
|
||||
self.replyContentView = replyContentView
|
||||
self.emojiImageView = emojiImageView
|
||||
|
||||
|
@ -93,6 +98,7 @@ public final class ComposeController: ViewController {
|
|||
|
||||
public var view: some View {
|
||||
ComposeView(poster: poster)
|
||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||
.environmentObject(draft)
|
||||
.environmentObject(mastodonController.instanceFeatures)
|
||||
}
|
||||
|
@ -102,7 +108,7 @@ public final class ComposeController: ViewController {
|
|||
return false
|
||||
}
|
||||
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
if draft.attachments.allSatisfy({ $0.data.type == .image }) {
|
||||
if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
|
||||
// if providers are videos, this technically allows invalid video/image combinations
|
||||
return itemProviders.count + draft.attachments.count <= 4
|
||||
} else {
|
||||
|
@ -118,7 +124,10 @@ public final class ComposeController: ViewController {
|
|||
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||
guard let attachment = object as? DraftAttachment else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.draft.attachments.append(attachment)
|
||||
guard self.attachmentsListController.canAddAttachment else { return }
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -132,7 +141,7 @@ public final class ComposeController: ViewController {
|
|||
if draft.hasContent {
|
||||
isShowingSaveDraftSheet = true
|
||||
} else {
|
||||
DraftsManager.shared.remove(draft)
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
config.dismiss(.cancel)
|
||||
}
|
||||
}
|
||||
|
@ -141,9 +150,7 @@ public final class ComposeController: ViewController {
|
|||
@MainActor
|
||||
func cancel(deleteDraft: Bool) {
|
||||
if deleteDraft {
|
||||
DraftsManager.shared.remove(draft)
|
||||
} else {
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
}
|
||||
config.dismiss(.cancel)
|
||||
}
|
||||
|
@ -166,13 +173,15 @@ public final class ComposeController: ViewController {
|
|||
do {
|
||||
try await poster.post()
|
||||
|
||||
didPostSuccessfully = true
|
||||
|
||||
// wait .25 seconds so the user can see the progress bar has completed
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
|
||||
config.dismiss(.post)
|
||||
|
||||
// don't unset the poster, so the ui remains disabled while dismissing
|
||||
|
||||
config.dismiss(.post)
|
||||
|
||||
} catch let error as PostService.Error {
|
||||
self.postError = error
|
||||
self.poster = nil
|
||||
|
@ -186,20 +195,20 @@ public final class ComposeController: ViewController {
|
|||
isShowingDraftsList = true
|
||||
}
|
||||
|
||||
func selectDraft(_ draft: Draft) {
|
||||
func selectDraft(_ newDraft: Draft) {
|
||||
if !self.draft.hasContent {
|
||||
DraftsManager.shared.remove(self.draft)
|
||||
DraftsPersistentContainer.shared.viewContext.delete(self.draft)
|
||||
}
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.save()
|
||||
|
||||
self.draft = draft
|
||||
self.draft = newDraft
|
||||
}
|
||||
|
||||
func onDisappear() {
|
||||
if !draft.hasContent {
|
||||
DraftsManager.shared.remove(draft)
|
||||
if !draft.hasContent || didPostSuccessfully {
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
}
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
func toggleContentWarning() {
|
||||
|
@ -297,7 +306,7 @@ public final class ComposeController: ViewController {
|
|||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
|
||||
HeaderView()
|
||||
HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
|
||||
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
|
@ -354,7 +363,7 @@ public final class ComposeController: ViewController {
|
|||
|
||||
@ViewBuilder
|
||||
private var postButton: some View {
|
||||
if draft.hasContent {
|
||||
if draft.hasContent || !controller.config.allowSwitchingDrafts {
|
||||
Button(action: controller.postStatus) {
|
||||
Text("Post")
|
||||
}
|
||||
|
|
|
@ -43,12 +43,12 @@ class DraftsController: ViewController {
|
|||
}
|
||||
|
||||
func deleteDraft(_ draft: Draft) {
|
||||
DraftsManager.shared.remove(draft)
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
}
|
||||
|
||||
func closeDrafts() {
|
||||
isPresented = false
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||
|
@ -65,18 +65,12 @@ class DraftsController: ViewController {
|
|||
struct DraftsView: View {
|
||||
@EnvironmentObject private var controller: DraftsController
|
||||
@EnvironmentObject private var currentDraft: Draft
|
||||
@ObservedObject private var draftsManager = DraftsManager.shared
|
||||
|
||||
private var visibleDrafts: [Draft] {
|
||||
draftsManager.sorted.filter {
|
||||
$0.accountID == controller.parent.mastodonController.accountInfo!.id && $0.id != currentDraft.id
|
||||
}
|
||||
}
|
||||
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(visibleDrafts) { draft in
|
||||
ForEach(drafts) { draft in
|
||||
Button(action: { controller.maybeSelectDraft(draft) }) {
|
||||
DraftRow(draft: draft)
|
||||
}
|
||||
|
@ -90,7 +84,7 @@ class DraftsController: ViewController {
|
|||
})
|
||||
}
|
||||
.onDelete { indices in
|
||||
indices.map { visibleDrafts[$0] }.forEach(controller.deleteDraft)
|
||||
indices.map { drafts[$0] }.forEach(controller.deleteDraft)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
|
@ -110,6 +104,9 @@ class DraftsController: ViewController {
|
|||
} message: { _ in
|
||||
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
||||
}
|
||||
.onAppear {
|
||||
drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID)
|
||||
}
|
||||
}
|
||||
|
||||
private var cancelButton: some View {
|
||||
|
@ -136,7 +133,7 @@ private struct DraftRow: View {
|
|||
.font(.body)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(draft.attachments) { attachment in
|
||||
ForEach(draft.draftAttachments) { attachment in
|
||||
AttachmentThumbnailView(attachment: attachment, fullSize: false)
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(5)
|
||||
|
|
|
@ -12,11 +12,11 @@ class PollController: ViewController {
|
|||
|
||||
unowned let parent: ComposeController
|
||||
var draft: Draft { parent.draft }
|
||||
let poll: Draft.Poll
|
||||
let poll: Poll
|
||||
|
||||
@Published var duration: Duration
|
||||
|
||||
init(parent: ComposeController, poll: Draft.Poll) {
|
||||
init(parent: ComposeController, poll: Poll) {
|
||||
self.parent = parent
|
||||
self.poll = poll
|
||||
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
|
||||
|
@ -34,11 +34,11 @@ class PollController: ViewController {
|
|||
}
|
||||
|
||||
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
||||
poll.options.move(fromOffsets: indices, toOffset: newIndex)
|
||||
poll.options.moveObjects(at: indices, to: newIndex)
|
||||
}
|
||||
|
||||
private func removeOption(_ option: Draft.Poll.Option) {
|
||||
poll.options.removeAll(where: { $0.id == option.id })
|
||||
private func removeOption(_ option: PollOption) {
|
||||
poll.options.remove(option)
|
||||
}
|
||||
|
||||
private var canAddOption: Bool {
|
||||
|
@ -50,12 +50,14 @@ class PollController: ViewController {
|
|||
}
|
||||
|
||||
private func addOption() {
|
||||
poll.options.append(.init(""))
|
||||
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
|
||||
option.poll = poll
|
||||
poll.options.add(option)
|
||||
}
|
||||
|
||||
struct PollView: View {
|
||||
@EnvironmentObject private var controller: PollController
|
||||
@EnvironmentObject private var poll: Draft.Poll
|
||||
@EnvironmentObject private var poll: Poll
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
|
@ -79,7 +81,7 @@ class PollController: ViewController {
|
|||
}
|
||||
|
||||
List {
|
||||
ForEach(poll.options) { option in
|
||||
ForEach($poll.pollOptions) { $option in
|
||||
PollOptionView(option: option, remove: { controller.removeOption(option) })
|
||||
.frame(height: 36)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||
|
|
|
@ -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,104 +0,0 @@
|
|||
//
|
||||
// DraftsManager.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 10/22/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
public class DraftsManager: Codable, ObservableObject {
|
||||
|
||||
public private(set) static var shared: DraftsManager = load()
|
||||
|
||||
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
||||
private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
|
||||
private static let saveQueue = DispatchQueue(label: "DraftsManager", qos: .utility)
|
||||
|
||||
public static func save() {
|
||||
saveQueue.async {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> DraftsManager {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
|
||||
return draftsManager
|
||||
}
|
||||
return DraftsManager()
|
||||
}
|
||||
|
||||
public static func migrate(from url: URL) -> Result<Void, any Error> {
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: archiveURL)
|
||||
try FileManager.default.moveItem(at: url, to: archiveURL)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
shared = load()
|
||||
return .success(())
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let dict = try? container.decode([UUID: SafeDraft].self, forKey: .drafts) {
|
||||
self.drafts = dict.compactMapValues { $0.draft }
|
||||
} else if let array = try? container.decode([SafeDraft].self, forKey: .drafts) {
|
||||
self.drafts = array.reduce(into: [:], { partialResult, safeDraft in
|
||||
if let draft = safeDraft.draft {
|
||||
partialResult[draft.id] = draft
|
||||
}
|
||||
})
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(drafts, forKey: .drafts)
|
||||
}
|
||||
|
||||
@Published private var drafts: [UUID: Draft] = [:]
|
||||
var sorted: [Draft] {
|
||||
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
|
||||
}
|
||||
|
||||
public func add(_ draft: Draft) {
|
||||
drafts[draft.id] = draft
|
||||
}
|
||||
|
||||
public func remove(_ draft: Draft) {
|
||||
drafts.removeValue(forKey: draft.id)
|
||||
}
|
||||
|
||||
public func getBy(id: UUID) -> Draft? {
|
||||
return drafts[id]
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case drafts
|
||||
}
|
||||
|
||||
// a container that always succeeds at decoding
|
||||
// so if a single draft can't be decoded, we don't lose all drafts
|
||||
struct SafeDraft: Decodable {
|
||||
let draft: Draft?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.draft = try? container.decode(Draft.self)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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() {
|
||||
switch attachment.data {
|
||||
case let .image(originalData, originalType: _):
|
||||
self.image = UIImage(data: originalData)
|
||||
case let .asset(asset):
|
||||
case .asset(let id):
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||
return
|
||||
}
|
||||
let size: CGSize
|
||||
if fullSize {
|
||||
size = PHImageManagerMaximumSize
|
||||
|
@ -77,18 +78,35 @@ struct AttachmentThumbnailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
case let .video(url):
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
case let .drawing(drawing):
|
||||
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
imageContentMode = .fit
|
||||
imageBackgroundColor = .white
|
||||
case let .gif(data):
|
||||
self.gifData = data
|
||||
|
||||
case .file(let url, let type):
|
||||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
} else if let data = try? Data(contentsOf: url) {
|
||||
if type == .gif {
|
||||
self.gifData = data
|
||||
} else if type.conforms(to: .image),
|
||||
let image = UIImage(data: data) {
|
||||
if fullSize {
|
||||
image.prepareForDisplay {
|
||||
self.image = $0
|
||||
}
|
||||
} else {
|
||||
image.prepareThumbnail(of: CGSize(width: 80, height: 80)) {
|
||||
self.image = $0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,10 @@ struct CurrentAccountView: View {
|
|||
@EnvironmentObject private var controller: ComposeController
|
||||
|
||||
var body: some View {
|
||||
controller.currentAccountContainerView(AnyView(currentAccount))
|
||||
}
|
||||
|
||||
private var currentAccount: some View {
|
||||
HStack(alignment: .top) {
|
||||
AvatarImageView(
|
||||
url: account?.avatar,
|
||||
|
|
|
@ -10,15 +10,12 @@ import Pachyderm
|
|||
import InstanceFeatures
|
||||
|
||||
struct HeaderView: View {
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
@EnvironmentObject private var draft: Draft
|
||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||
|
||||
private var charsRemaining: Int { controller.charactersRemaining }
|
||||
let currentAccount: (any AccountProtocol)?
|
||||
let charsRemaining: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
CurrentAccountView(account: controller.currentAccount)
|
||||
CurrentAccountView(account: currentAccount)
|
||||
.accessibilitySortPriority(1)
|
||||
|
||||
Spacer()
|
||||
|
|
|
@ -15,6 +15,7 @@ struct MainTextView: View {
|
|||
|
||||
@State private var hasFirstAppeared = false
|
||||
@State private var height: CGFloat?
|
||||
@State private var updateSelection: ((UITextView) -> Void)?
|
||||
private let minHeight: CGFloat = 150
|
||||
private var effectiveHeight: CGFloat { height ?? minHeight }
|
||||
|
||||
|
@ -34,7 +35,7 @@ struct MainTextView: View {
|
|||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, textDidChange: textDidChange)
|
||||
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, updateSelection: $updateSelection, textDidChange: textDidChange)
|
||||
}
|
||||
.frame(height: effectiveHeight)
|
||||
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
||||
|
@ -44,6 +45,11 @@ struct MainTextView: View {
|
|||
if !hasFirstAppeared {
|
||||
hasFirstAppeared = true
|
||||
controller.mainComposeTextViewBecomeFirstResponder = true
|
||||
if config.textSelectionStartsAtBeginning {
|
||||
updateSelection = { textView in
|
||||
textView.selectedTextRange = textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -57,6 +63,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
|
||||
@Binding var text: String
|
||||
@Binding var becomeFirstResponder: Bool
|
||||
@Binding var updateSelection: ((UITextView) -> Void)?
|
||||
let textDidChange: (UITextView) -> Void
|
||||
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
|
@ -85,6 +92,11 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
|
||||
context.coordinator.text = $text
|
||||
|
||||
if let updateSelection {
|
||||
updateSelection(uiView)
|
||||
self.updateSelection = nil
|
||||
}
|
||||
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
// the text view knows its new content size
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
@ -9,17 +9,17 @@ import SwiftUI
|
|||
|
||||
struct PollOptionView: View {
|
||||
@EnvironmentObject private var controller: PollController
|
||||
@EnvironmentObject private var poll: Draft.Poll
|
||||
@ObservedObject private var option: Draft.Poll.Option
|
||||
@EnvironmentObject private var poll: Poll
|
||||
@ObservedObject private var option: PollOption
|
||||
let remove: () -> Void
|
||||
|
||||
init(option: Draft.Poll.Option, remove: @escaping () -> Void) {
|
||||
init(option: PollOption, remove: @escaping () -> Void) {
|
||||
self.option = option
|
||||
self.remove = remove
|
||||
}
|
||||
|
||||
private var optionIndex: Int {
|
||||
poll.options.firstIndex(where: { $0.id == option.id }) ?? 0
|
||||
poll.options.index(of: option)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
|
@ -28,7 +28,8 @@ public struct AvatarImageView: View {
|
|||
.resizable()
|
||||
.frame(width: size, height: size)
|
||||
.cornerRadius(style.cornerRadiusFraction * size)
|
||||
.task {
|
||||
.task { @MainActor in
|
||||
image = nil
|
||||
if let url {
|
||||
image = await fetchAvatar(url)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct UserAccountInfo: Equatable, Hashable {
|
||||
public struct UserAccountInfo: Equatable, Hashable, Identifiable {
|
||||
public let id: String
|
||||
public let instanceURL: URL
|
||||
public let clientID: String
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Share View Controller-->
|
||||
<scene sceneID="ceB-am-kn3">
|
||||
<objects>
|
||||
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>TRUEPREDICATE</string>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.space.vaccor.Tusker</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,166 @@
|
|||
//
|
||||
// ShareHostingController.swift
|
||||
// ShareExtension
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import ComposeUI
|
||||
import TuskerComponents
|
||||
import WebURLFoundationExtras
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
class ShareHostingController: UIHostingController<ShareHostingController.View> {
|
||||
private static func fetchAvatar(_ url: URL) async -> UIImage? {
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
let image = UIImage(data: data) else {
|
||||
return nil
|
||||
}
|
||||
let size = 50 * UIScreen.main.scale
|
||||
return await image.byPreparingThumbnail(ofSize: CGSize(width: size, height: size)) ?? image
|
||||
}
|
||||
|
||||
private let controller: ComposeController
|
||||
|
||||
private var mastodonContextPublisher: CurrentValueSubject<ShareMastodonContext, Never>
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(draft: Draft, mastodonContext: ShareMastodonContext) {
|
||||
let mastodonContextPublisher = CurrentValueSubject<ShareMastodonContext, Never>(mastodonContext)
|
||||
self.mastodonContextPublisher = mastodonContextPublisher
|
||||
controller = ComposeController(
|
||||
draft: draft,
|
||||
config: ComposeUIConfig(),
|
||||
mastodonController: mastodonContext,
|
||||
fetchAvatar: Self.fetchAvatar,
|
||||
fetchStatus: { _ in fatalError("replies aren't allowed in share sheet") },
|
||||
displayNameLabel: { account, style, _ in AnyView(Text(account.displayName).font(.system(style))) },
|
||||
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
|
||||
replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") },
|
||||
emojiImageView: {
|
||||
AnyView(AsyncImage(url: URL($0.url)!) {
|
||||
$0
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} placeholder: {
|
||||
Image(systemName: "smiley.fill")
|
||||
})
|
||||
}
|
||||
)
|
||||
super.init(rootView: View(controller: controller))
|
||||
|
||||
updateConfig()
|
||||
|
||||
mastodonContextPublisher
|
||||
.sink { [unowned self] in
|
||||
self.controller.mastodonController = $0
|
||||
self.controller.draft.accountID = $0.accountInfo!.id
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
mastodonContextPublisher
|
||||
.flatMap { $0.$ownAccount }
|
||||
.sink { [unowned self] in self.controller.currentAccount = $0 }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
private func updateConfig() {
|
||||
var config = ComposeUIConfig()
|
||||
config.allowSwitchingDrafts = false
|
||||
config.textSelectionStartsAtBeginning = true
|
||||
// note: in the share sheet, we ignore this preference
|
||||
config.automaticallySaveDrafts = false
|
||||
|
||||
config.backgroundColor = Color(uiColor: .appBackground)
|
||||
config.groupedBackgroundColor = Color(uiColor: .appGroupedBackground)
|
||||
config.groupedCellBackgroundColor = Color(uiColor: .appGroupedCellBackground)
|
||||
config.fillColor = Color(uiColor: .appFill)
|
||||
switch Preferences.shared.avatarStyle {
|
||||
case .roundRect:
|
||||
config.avatarStyle = .roundRect
|
||||
case .circle:
|
||||
config.avatarStyle = .circle
|
||||
}
|
||||
config.useTwitterKeyboard = Preferences.shared.useTwitterKeyboard
|
||||
config.contentType = Preferences.shared.statusContentType
|
||||
config.requireAttachmentDescriptions = Preferences.shared.requireAttachmentDescriptions
|
||||
|
||||
config.dismiss = { [unowned self] in self.dismiss(mode: $0) }
|
||||
|
||||
controller.config = config
|
||||
}
|
||||
|
||||
private func dismiss(mode: DismissMode) {
|
||||
guard let extensionContext else { return }
|
||||
switch mode {
|
||||
case .cancel:
|
||||
extensionContext.cancelRequest(withError: Error.cancelled)
|
||||
case .post:
|
||||
extensionContext.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct View: SwiftUI.View {
|
||||
let controller: ComposeController
|
||||
|
||||
var body: some SwiftUI.View {
|
||||
ControllerView(controller: { controller })
|
||||
}
|
||||
}
|
||||
|
||||
enum Error: Swift.Error {
|
||||
case cancelled
|
||||
}
|
||||
}
|
||||
|
||||
// todo: shouldn't just copy this from the main Colors.swift
|
||||
extension UIColor {
|
||||
static let appBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!Preferences.shared.pureBlackDarkMode {
|
||||
return UIColor(hue: 230/360, saturation: 23/100, brightness: 10/100, alpha: 1)
|
||||
} else {
|
||||
return .systemBackground
|
||||
}
|
||||
}
|
||||
|
||||
static let appGroupedBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!Preferences.shared.pureBlackDarkMode {
|
||||
if traitCollection.userInterfaceLevel == .elevated {
|
||||
return UIColor(hue: 230/360, saturation: 23/100, brightness: 10/100, alpha: 1)
|
||||
} else {
|
||||
return UIColor(hue: 230/360, saturation: 23/100, brightness: 5/100, alpha: 1)
|
||||
}
|
||||
} else {
|
||||
return .systemGroupedBackground
|
||||
}
|
||||
}
|
||||
|
||||
static let appGroupedCellBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle {
|
||||
if Preferences.shared.pureBlackDarkMode {
|
||||
return .secondarySystemBackground
|
||||
} else {
|
||||
return .appFill
|
||||
}
|
||||
} else {
|
||||
return .systemBackground
|
||||
}
|
||||
}
|
||||
|
||||
static let appFill = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!Preferences.shared.pureBlackDarkMode {
|
||||
return UIColor(hue: 230/360, saturation: 20/100, brightness: 17/100, alpha: 1)
|
||||
} else {
|
||||
return .systemFill
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// ShareMastodonContext.swift
|
||||
// ShareExtension
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Pachyderm
|
||||
import ComposeUI
|
||||
import UserAccounts
|
||||
import InstanceFeatures
|
||||
import Combine
|
||||
|
||||
class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
||||
let accountInfo: UserAccountInfo?
|
||||
let client: Client
|
||||
let instanceFeatures: InstanceFeatures
|
||||
|
||||
@MainActor
|
||||
private var customEmojis: [Emoji]?
|
||||
|
||||
@Published var ownAccount: Account?
|
||||
|
||||
init(accountInfo: UserAccountInfo) {
|
||||
self.accountInfo = accountInfo
|
||||
self.client = Client(baseURL: accountInfo.instanceURL, accessToken: accountInfo.accessToken)
|
||||
self.instanceFeatures = InstanceFeatures()
|
||||
|
||||
Task { @MainActor in
|
||||
async let instance = try? await run(Client.getInstance()).0
|
||||
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
|
||||
self.client.nodeInfo { response in
|
||||
switch response {
|
||||
case .success(let nodeInfo, _):
|
||||
continuation.resume(returning: nodeInfo)
|
||||
case .failure(_):
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
})
|
||||
guard let instance = await instance else { return }
|
||||
self.instanceFeatures.update(instance: instance, nodeInfo: await nodeInfo)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
if let account = try? await run(Client.getSelfAccount()).0 {
|
||||
self.ownAccount = account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ComposeMastodonContext
|
||||
|
||||
func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
client.run(request) { response in
|
||||
switch response {
|
||||
case .success(let result, let pagination):
|
||||
continuation.resume(returning: (result, pagination))
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
|
||||
if let customEmojis {
|
||||
completion(customEmojis)
|
||||
} else {
|
||||
Task.detached { @MainActor in
|
||||
let emojis = (try? await self.run(Client.getCustomEmoji()).0) ?? []
|
||||
self.customEmojis = emojis
|
||||
completion(emojis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchCachedAccounts(query: String) -> [AccountProtocol] {
|
||||
return []
|
||||
}
|
||||
|
||||
func cachedRelationship(for accountID: String) -> RelationshipProtocol? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchCachedHashtags(query: String) -> [Hashtag] {
|
||||
return []
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
//
|
||||
// ShareViewController.swift
|
||||
// ShareExtension
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UserAccounts
|
||||
import ComposeUI
|
||||
import UniformTypeIdentifiers
|
||||
import TuskerPreferences
|
||||
import Combine
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private var state: State = .loading
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.tintColor = Preferences.shared.accentColor.color
|
||||
|
||||
if let account = UserAccountsManager.shared.getMostRecentAccount() {
|
||||
Task { @MainActor in
|
||||
let draft = await createDraft(account: account)
|
||||
state = .ok
|
||||
|
||||
let context = ShareMastodonContext(accountInfo: account)
|
||||
let host = ShareHostingController(draft: draft, mastodonContext: context)
|
||||
let nav = UINavigationController(rootViewController: host)
|
||||
self.addChild(nav)
|
||||
nav.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.view.addSubview(nav.view)
|
||||
NSLayoutConstraint.activate([
|
||||
nav.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
||||
nav.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
|
||||
nav.view.topAnchor.constraint(equalTo: self.view.topAnchor),
|
||||
nav.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
||||
])
|
||||
nav.didMove(toParent: self)
|
||||
}
|
||||
} else {
|
||||
state = .notLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
private func createDraft(account: UserAccountInfo) async -> Draft {
|
||||
let (text, attachments) = await getDraftConfigurationFromExtensionContext()
|
||||
|
||||
let draft = DraftsPersistentContainer.shared.createDraft(
|
||||
accountID: account.id,
|
||||
text: text,
|
||||
contentWarning: "",
|
||||
inReplyToID: nil,
|
||||
visibility: Preferences.shared.defaultPostVisibility,
|
||||
localOnly: false
|
||||
)
|
||||
|
||||
for attachment in attachments {
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
}
|
||||
draft.draftAttachments = attachments
|
||||
|
||||
return draft
|
||||
}
|
||||
|
||||
private func getDraftConfigurationFromExtensionContext() async -> (String, [DraftAttachment]) {
|
||||
guard let extensionContext,
|
||||
let inputItem = (extensionContext.inputItems as? [NSExtensionItem])?.first,
|
||||
let itemProvider = inputItem.attachments?.first else {
|
||||
return ("", [])
|
||||
}
|
||||
if let url: NSURL = await getObject(from: itemProvider) {
|
||||
if let title = inputItem.attributedTitle ?? inputItem.attributedContentText {
|
||||
return ("\n\n\(title.string)\n\(url.absoluteString ?? "")", [])
|
||||
} else {
|
||||
return ("\n\n\(url.absoluteString ?? "")", [])
|
||||
}
|
||||
} else if let text: NSString = await getObject(from: itemProvider) {
|
||||
return ("\n\n\(text)", [])
|
||||
} else if let attachment: DraftAttachment = await getObject(from: itemProvider) {
|
||||
return ("", [attachment])
|
||||
} else if let attributedContent = inputItem.attributedContentText {
|
||||
return ("\n\n\(attributedContent.string)", [])
|
||||
} else {
|
||||
return ("", [])
|
||||
}
|
||||
}
|
||||
|
||||
private func getObject<T: NSItemProviderReading>(from itemProvider: NSItemProvider) async -> T? {
|
||||
guard itemProvider.canLoadObject(ofClass: T.self) else {
|
||||
return nil
|
||||
}
|
||||
return await withCheckedContinuation({ continuation in
|
||||
itemProvider.loadObject(ofClass: T.self) { object, error in
|
||||
continuation.resume(returning: object as? T)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if case .notLoggedIn = state {
|
||||
let alert = UIAlertController(title: "Not Logged In", message: "You need to log in to an account through the app before you can post.", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [unowned self] _ in
|
||||
self.extensionContext!.cancelRequest(withError: Error.notLoggedIn)
|
||||
}))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum State {
|
||||
case loading
|
||||
case notLoggedIn
|
||||
case ok
|
||||
}
|
||||
|
||||
enum Error: Swift.Error {
|
||||
case notLoggedIn
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
//
|
||||
// SwitchAccountContainerView.swift
|
||||
// ShareExtension
|
||||
//
|
||||
// Created by Shadowfacts on 4/19/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UserAccounts
|
||||
import TuskerPreferences
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import ComposeUI
|
||||
|
||||
struct SwitchAccountContainerView: View {
|
||||
let content: AnyView
|
||||
let mastodonContextPublisher: CurrentValueSubject<ShareMastodonContext, Never>
|
||||
|
||||
var accounts: [UserAccountInfo] {
|
||||
UserAccountsManager.shared.accounts
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if accounts.count > 1 {
|
||||
Menu {
|
||||
ForEach(accounts) { account in
|
||||
Button(action: { selectAccount(account) }) {
|
||||
AccountButtonLabel(account: account)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(alignment: .center) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "arrowtriangle.up.fill")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 5)
|
||||
Image(systemName: "arrowtriangle.down.fill")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 5)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
content
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAccount(_ account: UserAccountInfo) {
|
||||
mastodonContextPublisher.send(ShareMastodonContext(accountInfo: account))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountButtonLabel: View {
|
||||
static let urlSession = URLSession(configuration: .ephemeral)
|
||||
|
||||
let account: UserAccountInfo
|
||||
@State private var avatarImage: Image?
|
||||
|
||||
var body: some View {
|
||||
label
|
||||
.task {
|
||||
await fetchAvatar()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var label: some View {
|
||||
// subtitles only started being supported on 16.4
|
||||
if #available(iOS 16.4, *) {
|
||||
Label {
|
||||
Text(account.username)
|
||||
} icon: {
|
||||
avatar
|
||||
}
|
||||
Text(account.instanceURL.host!)
|
||||
} else {
|
||||
Label {
|
||||
Text("@\(account.username)@\(account.instanceURL.host!)")
|
||||
} icon: {
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatar: some View {
|
||||
if let avatarImage {
|
||||
avatarImage
|
||||
} else {
|
||||
avatarPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarPlaceholder: Image {
|
||||
switch Preferences.shared.avatarStyle {
|
||||
case .circle:
|
||||
return Image(systemName: "person.crop.circle")
|
||||
case .roundRect:
|
||||
return Image(systemName: "person.crop.square")
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAvatar() async {
|
||||
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken, session: Self.urlSession)
|
||||
let account: Account? = await withCheckedContinuation({ continuation in
|
||||
client.run(Client.getSelfAccount()) { response in
|
||||
switch response {
|
||||
case .success(let account, _):
|
||||
continuation.resume(returning: account)
|
||||
case .failure(_):
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
})
|
||||
if let account,
|
||||
let avatarURL = account.avatar,
|
||||
let data = try? await Self.urlSession.data(from: avatarURL).0,
|
||||
let image = UIImage(data: data) {
|
||||
let size = CGSize(width: 50, height: 50)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
let clipped = renderer.image { context in
|
||||
let bounds = CGRect(origin: .zero, size: size)
|
||||
let path: UIBezierPath
|
||||
switch Preferences.shared.avatarStyle {
|
||||
case .circle:
|
||||
path = UIBezierPath(ovalIn: bounds)
|
||||
case .roundRect:
|
||||
path = UIBezierPath(roundedRect: bounds, cornerRadius: 5)
|
||||
}
|
||||
path.addClip()
|
||||
image.draw(in: bounds)
|
||||
}
|
||||
self.avatarImage = Image(uiImage: clipped)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -220,6 +220,17 @@
|
|||
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */; };
|
||||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
|
||||
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; };
|
||||
D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; };
|
||||
D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; };
|
||||
D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6A4531329EF64BA00032932 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D6A4532429EF665200032932 /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4532329EF665200032932 /* ComposeUI */; };
|
||||
D6A4532629EF665600032932 /* InstanceFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4532529EF665600032932 /* InstanceFeatures */; };
|
||||
D6A4532829EF665800032932 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4532729EF665800032932 /* Pachyderm */; };
|
||||
D6A4532A29EF665A00032932 /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4532929EF665A00032932 /* TuskerPreferences */; };
|
||||
D6A4532C29EF665D00032932 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4532B29EF665D00032932 /* UserAccounts */; };
|
||||
D6A4532E29EF7DDD00032932 /* ShareHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4532D29EF7DDD00032932 /* ShareHostingController.swift */; };
|
||||
D6A4533029EF7DEE00032932 /* ShareMastodonContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4532F29EF7DEE00032932 /* ShareMastodonContext.swift */; };
|
||||
D6A4533229F0CFCA00032932 /* SwitchAccountContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4533129F0CFCA00032932 /* SwitchAccountContainerView.swift */; };
|
||||
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; };
|
||||
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; };
|
||||
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; };
|
||||
|
@ -335,6 +346,13 @@
|
|||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
D6A4531B29EF64BA00032932 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D6A4531229EF64BA00032932;
|
||||
remoteInfo = ShareExtension;
|
||||
};
|
||||
D6D4DDE1212518A200E1C4BB /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
|
||||
|
@ -365,6 +383,7 @@
|
|||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
|
@ -597,6 +616,14 @@
|
|||
D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FollowNotificationGroupTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D6A4531529EF64BA00032932 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
|
||||
D6A4531A29EF64BA00032932 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D6A4531E29EF64BA00032932 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
D6A4532D29EF7DDD00032932 /* ShareHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareHostingController.swift; sourceTree = "<group>"; };
|
||||
D6A4532F29EF7DEE00032932 /* ShareMastodonContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareMastodonContext.swift; sourceTree = "<group>"; };
|
||||
D6A4533129F0CFCA00032932 /* SwitchAccountContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchAccountContainerView.swift; sourceTree = "<group>"; };
|
||||
D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = "<group>"; };
|
||||
D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = "<group>"; };
|
||||
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; };
|
||||
|
@ -723,6 +750,18 @@
|
|||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
D6A4531029EF64BA00032932 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6A4532829EF665800032932 /* Pachyderm in Frameworks */,
|
||||
D6A4532A29EF665A00032932 /* TuskerPreferences in Frameworks */,
|
||||
D6A4532629EF665600032932 /* InstanceFeatures in Frameworks */,
|
||||
D6A4532C29EF665D00032932 /* UserAccounts in Frameworks */,
|
||||
D6A4532429EF665200032932 /* ComposeUI in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D6D4DDC9212518A000E1C4BB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
@ -1264,6 +1303,20 @@
|
|||
path = "Status Action Account List";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6A4531429EF64BA00032932 /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6A4531E29EF64BA00032932 /* ShareExtension.entitlements */,
|
||||
D6A4532F29EF7DEE00032932 /* ShareMastodonContext.swift */,
|
||||
D6A4531529EF64BA00032932 /* ShareViewController.swift */,
|
||||
D6A4532D29EF7DDD00032932 /* ShareHostingController.swift */,
|
||||
D6A4533129F0CFCA00032932 /* SwitchAccountContainerView.swift */,
|
||||
D6A4531729EF64BA00032932 /* MainInterface.storyboard */,
|
||||
D6A4531A29EF64BA00032932 /* Info.plist */,
|
||||
);
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1408,6 +1461,7 @@
|
|||
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||
D6E343A9265AAD6B00C4AA01 /* OpenInTusker */,
|
||||
D6A4531429EF64BA00032932 /* ShareExtension */,
|
||||
D6D4DDCD212518A000E1C4BB /* Products */,
|
||||
D65A37F221472F300087646E /* Frameworks */,
|
||||
);
|
||||
|
@ -1420,6 +1474,7 @@
|
|||
D6D4DDE0212518A200E1C4BB /* TuskerTests.xctest */,
|
||||
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */,
|
||||
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */,
|
||||
D6A4531329EF64BA00032932 /* ShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1578,6 +1633,30 @@
|
|||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
D6A4531229EF64BA00032932 /* ShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D6A4532229EF64BA00032932 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||
buildPhases = (
|
||||
D6A4530F29EF64BA00032932 /* Sources */,
|
||||
D6A4531029EF64BA00032932 /* Frameworks */,
|
||||
D6A4531129EF64BA00032932 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = ShareExtension;
|
||||
packageProductDependencies = (
|
||||
D6A4532329EF665200032932 /* ComposeUI */,
|
||||
D6A4532529EF665600032932 /* InstanceFeatures */,
|
||||
D6A4532729EF665800032932 /* Pachyderm */,
|
||||
D6A4532929EF665A00032932 /* TuskerPreferences */,
|
||||
D6A4532B29EF665D00032932 /* UserAccounts */,
|
||||
);
|
||||
productName = ShareExtension;
|
||||
productReference = D6A4531329EF64BA00032932 /* ShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
D6D4DDCB212518A000E1C4BB /* Tusker */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D6D4DDF4212518A200E1C4BB /* Build configuration list for PBXNativeTarget "Tusker" */;
|
||||
|
@ -1594,6 +1673,7 @@
|
|||
);
|
||||
dependencies = (
|
||||
D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */,
|
||||
D6A4531C29EF64BA00032932 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Tusker;
|
||||
packageProductDependencies = (
|
||||
|
@ -1676,10 +1756,13 @@
|
|||
D6D4DDC4212518A000E1C4BB /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1250;
|
||||
LastSwiftUpdateCheck = 1430;
|
||||
LastUpgradeCheck = 1400;
|
||||
ORGANIZATIONNAME = Shadowfacts;
|
||||
TargetAttributes = {
|
||||
D6A4531229EF64BA00032932 = {
|
||||
CreatedOnToolsVersion = 14.3;
|
||||
};
|
||||
D6D4DDCB212518A000E1C4BB = {
|
||||
CreatedOnToolsVersion = 10.0;
|
||||
LastSwiftMigration = 1420;
|
||||
|
@ -1728,11 +1811,20 @@
|
|||
D6D4DDDF212518A200E1C4BB /* TuskerTests */,
|
||||
D6D4DDEA212518A200E1C4BB /* TuskerUITests */,
|
||||
D6E343A7265AAD6B00C4AA01 /* OpenInTusker */,
|
||||
D6A4531229EF64BA00032932 /* ShareExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
D6A4531129EF64BA00032932 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D6D4DDCA212518A000E1C4BB /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
@ -1832,6 +1924,17 @@
|
|||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
D6A4530F29EF64BA00032932 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6A4533229F0CFCA00032932 /* SwitchAccountContainerView.swift in Sources */,
|
||||
D6A4532E29EF7DDD00032932 /* ShareHostingController.swift in Sources */,
|
||||
D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */,
|
||||
D6A4533029EF7DEE00032932 /* ShareMastodonContext.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D6D4DDC8212518A000E1C4BB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
@ -2150,6 +2253,11 @@
|
|||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
D6A4531C29EF64BA00032932 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D6A4531229EF64BA00032932 /* ShareExtension */;
|
||||
targetProxy = D6A4531B29EF64BA00032932 /* PBXContainerItemProxy */;
|
||||
};
|
||||
D6D4DDE2212518A200E1C4BB /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D6D4DDCB212518A000E1C4BB /* Tusker */;
|
||||
|
@ -2169,6 +2277,14 @@
|
|||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
D6A4531729EF64BA00032932 /* MainInterface.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
D6A4531829EF64BA00032932 /* Base */,
|
||||
);
|
||||
name = MainInterface.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
|
@ -2349,6 +2465,93 @@
|
|||
};
|
||||
name = Dist;
|
||||
};
|
||||
D6A4531F29EF64BA00032932 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D6A4532029EF64BA00032932 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
D6A4532129EF64BA00032932 /* Dist */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Dist;
|
||||
};
|
||||
D6D4DDF2212518A200E1C4BB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = D6D706A829498C82000827ED /* Tusker.xcconfig */;
|
||||
|
@ -2667,6 +2870,16 @@
|
|||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
D6A4532229EF64BA00032932 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D6A4531F29EF64BA00032932 /* Debug */,
|
||||
D6A4532029EF64BA00032932 /* Release */,
|
||||
D6A4532129EF64BA00032932 /* Dist */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
D6D4DDC7212518A000E1C4BB /* Build configuration list for PBXProject "Tusker" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
@ -2791,6 +3004,26 @@
|
|||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Pachyderm;
|
||||
};
|
||||
D6A4532329EF665200032932 /* ComposeUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ComposeUI;
|
||||
};
|
||||
D6A4532529EF665600032932 /* InstanceFeatures */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = InstanceFeatures;
|
||||
};
|
||||
D6A4532729EF665800032932 /* Pachyderm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Pachyderm;
|
||||
};
|
||||
D6A4532929EF665A00032932 /* TuskerPreferences */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = TuskerPreferences;
|
||||
};
|
||||
D6A4532B29EF665D00032932 /* UserAccounts */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = UserAccounts;
|
||||
};
|
||||
D6B0026D29B5248800C70BE2 /* UserAccounts */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = UserAccounts;
|
||||
|
|
|
@ -496,7 +496,7 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
acctsToMention = acctsToMention.uniques()
|
||||
|
||||
let draft = Draft(
|
||||
return DraftsPersistentContainer.shared.createDraft(
|
||||
accountID: accountInfo!.id,
|
||||
text: text ?? acctsToMention.map { "@\($0) " }.joined(),
|
||||
contentWarning: contentWarning,
|
||||
|
@ -504,8 +504,6 @@ class MastodonController: ObservableObject {
|
|||
visibility: visibility,
|
||||
localOnly: localOnly
|
||||
)
|
||||
DraftsManager.shared.add(draft)
|
||||
return draft
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
|
|||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
configureSentry()
|
||||
swizzleStatusBar()
|
||||
|
@ -63,9 +62,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
if FileManager.default.fileExists(atPath: oldDraftsFile.path) {
|
||||
if case .failure(let error) = DraftsManager.migrate(from: oldDraftsFile) {
|
||||
SentrySDK.capture(error: error)
|
||||
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) {
|
||||
DraftsPersistentContainer.shared.migrate(from: url) {
|
||||
if case .failure(let error) = $0 {
|
||||
SentrySDK.capture(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,11 +77,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
|||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.save()
|
||||
|
||||
if let window = window,
|
||||
let nav = window.rootViewController as? UINavigationController,
|
||||
let compose = nav.topViewController as? ComposeHostingController {
|
||||
let compose = nav.topViewController as? ComposeHostingController,
|
||||
!compose.controller.didPostSuccessfully {
|
||||
scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,7 +88,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||
|
||||
Preferences.save()
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
|
@ -101,7 +101,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
|
||||
Preferences.save()
|
||||
DraftsManager.save()
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import ComposeUI
|
||||
import Combine
|
||||
|
@ -27,18 +28,18 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
let controller: ComposeController
|
||||
let mastodonController: MastodonController
|
||||
|
||||
|
||||
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
|
||||
private var drawingCompletion: ((PKDrawing) -> Void)?
|
||||
|
||||
init(draft: Draft?, mastodonController: MastodonController) {
|
||||
let draft = draft ?? mastodonController.createDraft()
|
||||
DraftsManager.shared.add(draft)
|
||||
|
||||
self.controller = ComposeController(
|
||||
draft: draft,
|
||||
config: ComposeUIConfig(),
|
||||
mastodonController: mastodonController,
|
||||
fetchAvatar: { await ImageCache.avatars.get($0).1 },
|
||||
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
|
||||
fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
|
||||
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) },
|
||||
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
|
||||
|
@ -52,7 +53,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
|
||||
self.updateConfig()
|
||||
|
||||
pasteConfiguration = UIPasteConfiguration(forAccepting: ComposeUI.DraftAttachment.self)
|
||||
pasteConfiguration = UIPasteConfiguration(forAccepting: DraftAttachment.self)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
|
|
@ -103,7 +103,7 @@ class UserActivityManager {
|
|||
let uuid = UUID(uuidString: idStr) else {
|
||||
return nil
|
||||
}
|
||||
return DraftsManager.shared.getBy(id: uuid)
|
||||
return DraftsPersistentContainer.shared.getDraft(id: uuid)
|
||||
}
|
||||
|
||||
static func getDuckedDraft(from activity: NSUserActivity) -> Draft? {
|
||||
|
@ -111,7 +111,7 @@ class UserActivityManager {
|
|||
let uuid = UUID(uuidString: idStr) else {
|
||||
return nil
|
||||
}
|
||||
return DraftsManager.shared.getBy(id: uuid)
|
||||
return DraftsPersistentContainer.shared.getDraft(id: uuid)
|
||||
}
|
||||
|
||||
// MARK: - Check Notifications
|
||||
|
|
Loading…
Reference in New Issue