Compare commits

...

25 Commits

Author SHA1 Message Date
Shadowfacts bda8fdb1b9 Bump build number and update changelog 2023-04-22 23:31:27 -04:00
Shadowfacts f361517a92 Fix crash on first launch after updating from build 77 2023-04-22 23:22:38 -04:00
Shadowfacts a12afb8dc2 Fix sharing extension only using first attachment 2023-04-22 22:43:00 -04:00
Shadowfacts de1a97d357 Use actual activation rule for sharing extension 2023-04-22 22:34:47 -04:00
Shadowfacts c17cf460d7 Fix post error messages not being displayed correctly 2023-04-22 22:30:27 -04:00
Shadowfacts 8ff20bf7aa Disable unused test targets 2023-04-22 22:23:43 -04:00
Shadowfacts 205056f636 Fix draft being deleted too early causing empty UI during dismiss compose animation 2023-04-22 22:18:46 -04:00
Shadowfacts 40197e04cf Fix attachment description observation trying to access properties of deleted object 2023-04-22 22:18:21 -04:00
Shadowfacts 2249e5a315 Fix DraftAttachment being accessed off main thread 2023-04-22 22:03:52 -04:00
Shadowfacts bff1ea8b9d Merge branch 'share-sheet-extension' into develop 2023-04-22 21:59:14 -04:00
Shadowfacts b614226871 Fix avatars in share sheet being blurry 2023-04-22 21:48:12 -04:00
Shadowfacts f51f3c8a94 Use CoreData for drafts store 2023-04-22 21:40:29 -04:00
Shadowfacts 074a296a68 Fix Post button always being disabled when require attachment descriptions is enabled
Also fix post button state not updating when description edited

Closes #371
2023-04-21 18:02:30 -04:00
Shadowfacts 2874e4bfd3 Coordinate DraftsManager reading writing between processes 2023-04-21 17:24:40 -04:00
Shadowfacts 74a157d26c Fix drafts from share sheet not being saved 2023-04-19 22:27:25 -04:00
Shadowfacts 3d3fc3f515 Allow switching accounts from share sheet 2023-04-19 22:20:05 -04:00
Shadowfacts 6c371f868f Initial share extension implementation 2023-04-18 21:55:14 -04:00
Shadowfacts 06855420da Move preferences to shared package 2023-04-18 19:47:49 -04:00
Shadowfacts 0d7cc69947 Fix not being able to close draft when automatic save preference is off 2023-04-18 15:17:42 -04:00
Shadowfacts cfc69627e5 Fix crash when creating menu actions for status w/o URL 2023-04-18 10:19:53 -04:00
Shadowfacts 160f48679b Handle HTTP 206 responses from timelines endpoint 2023-04-18 10:16:38 -04:00
Shadowfacts 4931665b45 Log Sentry installation ID
So when the user taps Get Support and logs are sent we can cross-ref
with recent crashes
2023-04-18 10:10:15 -04:00
Shadowfacts 849882287f Fix crash when pasting screenshots, not being able to paste gifs 2023-04-17 20:14:59 -04:00
Shadowfacts 436159bd46 Show reblogger's avatar on reblogged posts 2023-04-17 11:19:37 -04:00
Shadowfacts 2224dbebb8 Remove old code 2023-04-17 10:08:18 -04:00
62 changed files with 2194 additions and 700 deletions

View File

@ -1,5 +1,21 @@
# Changelog # Changelog
## 2023.5 (80)
This build adds a Share Sheet extension and introduces further Compose screen refactors.
Features/Improvements:
- Add Share Sheet extension
- Show reblogger's avatar on reblogged posts
Bugfixes:
- Fix not being able to close Compose screen when Automatically Save Drafts preference is off
- Fix Post button always being disabled when Require Attachment Descriptions preference is on
- Fix crash when pasting screenshots
- Fix not being able to paste gifs
- Don't consider HTTP 206 responses to timeline requests to be errors
- Fix crash when displaying menu for statuses missing URLs
- Fix errors while posting not displaying useful error messages
## 2023.5 (77) ## 2023.5 (77)
The Compose screen has been substantially refactored in this build, in preparation for upcoming features, so please report any issues you encounter! The Compose screen has been substantially refactored in this build, in preparation for upcoming features, so please report any issues you encounter!

View File

@ -1,6 +1,6 @@
// //
// PostService.swift // PostService.swift
// Tusker // ComposeUI
// //
// Created by Shadowfacts on 4/27/22. // Created by Shadowfacts on 4/27/22.
// Copyright © 2022 Shadowfacts. All rights reserved. // Copyright © 2022 Shadowfacts. All rights reserved.
@ -10,6 +10,7 @@ import Foundation
import Pachyderm import Pachyderm
import UniformTypeIdentifiers import UniformTypeIdentifiers
@MainActor
class PostService: ObservableObject { class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext private let mastodonController: ComposeMastodonContext
private let config: ComposeUIConfig private let config: ComposeUIConfig
@ -26,14 +27,13 @@ class PostService: ObservableObject {
self.totalSteps = 2 + (draft.attachments.count * 2) self.totalSteps = 2 + (draft.attachments.count * 2)
} }
@MainActor
func post() async throws { func post() async throws {
guard draft.hasContent else { guard draft.hasContent else {
return return
} }
// save before posting, so if a crash occurs during network request, the status won't be lost // save before posting, so if a crash occurs during network request, the status won't be lost
DraftsManager.save() DraftsPersistentContainer.shared.save()
let uploadedAttachments = try await uploadAttachments() let uploadedAttachments = try await uploadAttachments()
@ -49,7 +49,7 @@ class PostService: ObservableObject {
spoilerText: contentWarning, spoilerText: contentWarning,
visibility: draft.visibility, visibility: draft.visibility,
language: nil, language: nil,
pollOptions: draft.poll?.options.map(\.text), pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple, pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
@ -57,9 +57,6 @@ class PostService: ObservableObject {
do { do {
let (_, _) = try await mastodonController.run(request) let (_, _) = try await mastodonController.run(request)
currentStep += 1 currentStep += 1
DraftsManager.shared.remove(self.draft)
DraftsManager.save()
} catch let error as Client.Error { } catch let error as Client.Error {
throw Error.posting(error) throw Error.posting(error)
} }
@ -68,7 +65,7 @@ class PostService: ObservableObject {
private func uploadAttachments() async throws -> [Attachment] { private func uploadAttachments() async throws -> [Attachment] {
var attachments: [Attachment] = [] var attachments: [Attachment] = []
attachments.reserveCapacity(draft.attachments.count) attachments.reserveCapacity(draft.attachments.count)
for (index, attachment) in draft.attachments.enumerated() { for (index, attachment) in draft.draftAttachments.enumerated() {
let data: Data let data: Data
let utType: UTType let utType: UTType
do { do {
@ -90,7 +87,7 @@ class PostService: ObservableObject {
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) { private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
attachment.data.getData(features: mastodonController.instanceFeatures) { result in attachment.getData(features: mastodonController.instanceFeatures) { result in
switch result { switch result {
case let .success(res): case let .success(res):
continuation.resume(returning: res) continuation.resume(returning: res)

View File

@ -12,16 +12,24 @@ import PencilKit
import TuskerComponents import TuskerComponents
public struct ComposeUIConfig { public struct ComposeUIConfig {
// Config
public var allowSwitchingDrafts = true
public var textSelectionStartsAtBeginning = false
// Style
public var backgroundColor = Color(uiColor: .systemBackground) public var backgroundColor = Color(uiColor: .systemBackground)
public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground) public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground)
public var groupedCellBackgroundColor = Color(uiColor: .systemBackground) public var groupedCellBackgroundColor = Color(uiColor: .systemBackground)
public var fillColor = Color(uiColor: .systemFill) public var fillColor = Color(uiColor: .systemFill)
public var avatarStyle = AvatarImageView.Style.roundRect public var avatarStyle = AvatarImageView.Style.roundRect
// Preferences
public var useTwitterKeyboard = false public var useTwitterKeyboard = false
public var contentType = StatusContentType.plain public var contentType = StatusContentType.plain
public var automaticallySaveDrafts = false public var automaticallySaveDrafts = false
public var requireAttachmentDescriptions = false public var requireAttachmentDescriptions = false
// Host callbacks
public var dismiss: @MainActor (DismissMode) -> Void = { _ in } public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)? public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?

View File

@ -16,9 +16,26 @@ class AttachmentRowController: ViewController {
@Published var descriptionMode: DescriptionMode = .allowEntry @Published var descriptionMode: DescriptionMode = .allowEntry
@Published var textRecognitionError: Error? @Published var textRecognitionError: Error?
private var descriptionObservation: NSKeyValueObservation?
init(parent: ComposeController, attachment: DraftAttachment) { init(parent: ComposeController, attachment: DraftAttachment) {
self.parent = parent self.parent = parent
self.attachment = attachment 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 { var view: some View {
@ -27,7 +44,7 @@ class AttachmentRowController: ViewController {
private func removeAttachment() { private func removeAttachment() {
withAnimation { withAnimation {
parent.draft.attachments.removeAll(where: { $0.id == attachment.id }) parent.draft.attachments.remove(attachment)
} }
} }
@ -36,7 +53,7 @@ class AttachmentRowController: ViewController {
return return
} }
parent.config.presentDrawing?(drawing) { newDrawing in parent.config.presentDrawing?(drawing) { newDrawing in
self.attachment.data = .drawing(newDrawing) self.attachment.drawing = newDrawing
} }
} }
@ -44,7 +61,7 @@ class AttachmentRowController: ViewController {
descriptionMode = .recognizingText descriptionMode = .recognizingText
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
self.attachment.data.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
let data: Data let data: Data
switch result { switch result {
case .success((let d, _)): case .success((let d, _)):
@ -103,11 +120,11 @@ class AttachmentRowController: ViewController {
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
if case .drawing(_) = attachment.data { if attachment.drawingData != nil {
Button(action: controller.editDrawing) { Button(action: controller.editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw") Label("Edit Drawing", systemImage: "hand.draw")
} }
} else if attachment.data.type == .image { } else if attachment.type == .image {
Button(action: controller.recognizeText) { Button(action: controller.recognizeText) {
Label("Recognize Text", systemImage: "doc.text.viewfinder") Label("Recognize Text", systemImage: "doc.text.viewfinder")
} }
@ -138,6 +155,7 @@ class AttachmentRowController: ViewController {
} message: { error in } message: { error in
Text(error.localizedDescription) Text(error.localizedDescription)
} }
.onAppear(perform: controller.updateAttachmentDescriptionState)
} }
} }

View File

@ -20,19 +20,21 @@ class AttachmentsListController: ViewController {
private var requiresAttachmentDescriptions: Bool { private var requiresAttachmentDescriptions: Bool {
if parent.config.requireAttachmentDescriptions { if parent.config.requireAttachmentDescriptions {
return draft.attachments.allSatisfy { if draft.attachments.count == 0 {
!$0.attachmentDescription.isEmpty return false
} else {
return !parent.attachmentsMissingDescriptions.isEmpty
} }
} else { } else {
return false return false
} }
} }
private var validAttachmentCombination: Bool { var validAttachmentCombination: Bool {
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
return true return true
} else if draft.attachments.contains(where: { $0.data.type == .video }) && } else if draft.attachments.count > 1,
draft.attachments.count > 1 { draft.draftAttachments.contains(where: { $0.type == .video }) {
return false return false
} else if draft.attachments.count > 4 { } else if draft.attachments.count > 4 {
return false return false
@ -44,9 +46,9 @@ class AttachmentsListController: ViewController {
self.parent = parent self.parent = parent
} }
private var canAddAttachment: Bool { var canAddAttachment: Bool {
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
} else { } else {
return true return true
} }
@ -56,7 +58,7 @@ class AttachmentsListController: ViewController {
if parent.mastodonController.instanceFeatures.pollsAndAttachments { if parent.mastodonController.instanceFeatures.pollsAndAttachments {
return true return true
} else { } else {
return draft.attachments.isEmpty return draft.attachments.count == 0
} }
} }
@ -65,21 +67,27 @@ class AttachmentsListController: ViewController {
} }
private func moveAttachments(from source: IndexSet, to destination: Int) { private func moveAttachments(from source: IndexSet, to destination: Int) {
draft.attachments.move(fromOffsets: source, toOffset: destination) // just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
// results in the order switching back to the previous order and then to the correct one
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
var array = draft.draftAttachments
array.move(fromOffsets: source, toOffset: destination)
draft.attachments = NSMutableOrderedSet(array: array)
} }
private func deleteAttachments(at indices: IndexSet) { private func deleteAttachments(at indices: IndexSet) {
draft.attachments.remove(atOffsets: indices) draft.attachments.removeObjects(at: indices)
} }
@MainActor private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) async {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return } guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
guard self.canAddAttachment else { return } guard self.canAddAttachment else { return }
self.draft.attachments.append(attachment) DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
} }
} }
} }
@ -87,15 +95,16 @@ class AttachmentsListController: ViewController {
private func addImage() { private func addImage() {
parent.config.presentAssetPicker?({ results in parent.config.presentAssetPicker?({ results in
Task { self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
await self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
}
}) })
} }
private func addDrawing() { private func addDrawing() {
parent.config.presentDrawing?(PKDrawing()) { drawing in parent.config.presentDrawing?(PKDrawing()) { drawing in
self.draft.attachments.append(DraftAttachment(data: .drawing(drawing))) let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.drawing = drawing
attachment.draft = self.draft
self.draft.attachments.add(attachment)
} }
} }
@ -103,7 +112,7 @@ class AttachmentsListController: ViewController {
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
withAnimation { withAnimation {
draft.poll = draft.poll == nil ? Draft.Poll() : nil draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
} }
} }
@ -136,22 +145,22 @@ class AttachmentsListController: ViewController {
} }
private var attachmentsList: some View { private var attachmentsList: some View {
ForEach(draft.attachments) { attachment in ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) }) ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
.onDrag {
NSItemProvider(object: attachment)
}
} }
.onMove(perform: controller.moveAttachments) .onMove(perform: controller.moveAttachments)
.onDelete(perform: controller.deleteAttachments) .onDelete(perform: controller.deleteAttachments)
.conditionally(controller.canAddAttachment) { .conditionally(controller.canAddAttachment) {
$0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in $0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in
Task { controller.insertAttachments(at: offset, itemProviders: providers)
await controller.insertAttachments(at: offset, itemProviders: providers)
}
}) })
} }
// only sort of works, see #240
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in
controller.insertAttachments(at: 0, itemProviders: providers)
return true
}
} }
private var addImageButton: some View { private var addImageButton: some View {

View File

@ -13,15 +13,17 @@ import TuskerComponents
public final class ComposeController: ViewController { public final class ComposeController: ViewController {
public typealias FetchStatus = (String) -> (any StatusProtocol)? public typealias FetchStatus = (String) -> (any StatusProtocol)?
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView 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 ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
public typealias EmojiImageView = (Emoji) -> AnyView public typealias EmojiImageView = (Emoji) -> AnyView
@Published public private(set) var draft: Draft @Published public private(set) var draft: Draft
@Published public var config: ComposeUIConfig @Published public var config: ComposeUIConfig
let mastodonController: ComposeMastodonContext @Published public var mastodonController: ComposeMastodonContext
let fetchAvatar: AvatarImageView.FetchAvatar let fetchAvatar: AvatarImageView.FetchAvatar
let fetchStatus: FetchStatus let fetchStatus: FetchStatus
let displayNameLabel: DisplayNameLabel let displayNameLabel: DisplayNameLabel
let currentAccountContainerView: CurrentAccountContainerView
let replyContentView: ReplyContentView let replyContentView: ReplyContentView
let emojiImageView: EmojiImageView let emojiImageView: EmojiImageView
@ -32,6 +34,9 @@ public final class ComposeController: ViewController {
@Published var toolbarController: ToolbarController! @Published var toolbarController: ToolbarController!
@Published var attachmentsListController: AttachmentsListController! @Published var attachmentsListController: AttachmentsListController!
// this property is here rather than on the AttachmentsListController so that the ComposeView
// updates when it changes, because changes to it may alter postButtonEnabled
@Published var attachmentsMissingDescriptions = Set<UUID>()
@Published var contentWarningBecomeFirstResponder = false @Published var contentWarningBecomeFirstResponder = false
@Published var mainComposeTextViewBecomeFirstResponder = false @Published var mainComposeTextViewBecomeFirstResponder = false
@Published var currentInput: (any ComposeInput)? = nil @Published var currentInput: (any ComposeInput)? = nil
@ -39,7 +44,8 @@ public final class ComposeController: ViewController {
@Published var isShowingSaveDraftSheet = false @Published var isShowingSaveDraftSheet = false
@Published var isShowingDraftsList = false @Published var isShowingDraftsList = false
@Published var poster: PostService? @Published var poster: PostService?
@Published var postError: (any Error)? @Published var postError: PostService.Error?
@Published public private(set) var didPostSuccessfully = false
var isPosting: Bool { var isPosting: Bool {
poster != nil poster != nil
@ -61,7 +67,7 @@ public final class ComposeController: ViewController {
} }
private var isPollValid: Bool { private var isPollValid: Bool {
draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty } draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
} }
public init( public init(
@ -71,6 +77,7 @@ public final class ComposeController: ViewController {
fetchAvatar: @escaping AvatarImageView.FetchAvatar, fetchAvatar: @escaping AvatarImageView.FetchAvatar,
fetchStatus: @escaping FetchStatus, fetchStatus: @escaping FetchStatus,
displayNameLabel: @escaping DisplayNameLabel, displayNameLabel: @escaping DisplayNameLabel,
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
replyContentView: @escaping ReplyContentView, replyContentView: @escaping ReplyContentView,
emojiImageView: @escaping EmojiImageView emojiImageView: @escaping EmojiImageView
) { ) {
@ -80,6 +87,7 @@ public final class ComposeController: ViewController {
self.fetchAvatar = fetchAvatar self.fetchAvatar = fetchAvatar
self.fetchStatus = fetchStatus self.fetchStatus = fetchStatus
self.displayNameLabel = displayNameLabel self.displayNameLabel = displayNameLabel
self.currentAccountContainerView = currentAccountContainerView
self.replyContentView = replyContentView self.replyContentView = replyContentView
self.emojiImageView = emojiImageView self.emojiImageView = emojiImageView
@ -90,6 +98,7 @@ public final class ComposeController: ViewController {
public var view: some View { public var view: some View {
ComposeView(poster: poster) ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft) .environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures) .environmentObject(mastodonController.instanceFeatures)
} }
@ -99,7 +108,7 @@ public final class ComposeController: ViewController {
return false return false
} }
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
if draft.attachments.allSatisfy({ $0.data.type == .image }) { if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
// if providers are videos, this technically allows invalid video/image combinations // if providers are videos, this technically allows invalid video/image combinations
return itemProviders.count + draft.attachments.count <= 4 return itemProviders.count + draft.attachments.count <= 4
} else { } else {
@ -115,7 +124,10 @@ public final class ComposeController: ViewController {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return } guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.draft.attachments.append(attachment) guard self.attachmentsListController.canAddAttachment else { return }
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
} }
} }
} }
@ -129,12 +141,20 @@ public final class ComposeController: ViewController {
if draft.hasContent { if draft.hasContent {
isShowingSaveDraftSheet = true isShowingSaveDraftSheet = true
} else { } else {
DraftsManager.shared.remove(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
config.dismiss(.cancel) config.dismiss(.cancel)
} }
} }
} }
@MainActor
func cancel(deleteDraft: Bool) {
if deleteDraft {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
config.dismiss(.cancel)
}
func postStatus() { func postStatus() {
guard !isPosting, guard !isPosting,
draft.hasContent else { draft.hasContent else {
@ -153,13 +173,15 @@ public final class ComposeController: ViewController {
do { do {
try await poster.post() try await poster.post()
didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed // wait .25 seconds so the user can see the progress bar has completed
try? await Task.sleep(nanoseconds: 250_000_000) try? await Task.sleep(nanoseconds: 250_000_000)
config.dismiss(.post)
// don't unset the poster, so the ui remains disabled while dismissing // don't unset the poster, so the ui remains disabled while dismissing
config.dismiss(.post)
} catch let error as PostService.Error { } catch let error as PostService.Error {
self.postError = error self.postError = error
self.poster = nil self.poster = nil
@ -173,20 +195,20 @@ public final class ComposeController: ViewController {
isShowingDraftsList = true isShowingDraftsList = true
} }
func selectDraft(_ draft: Draft) { func selectDraft(_ newDraft: Draft) {
if !self.draft.hasContent { if !self.draft.hasContent {
DraftsManager.shared.remove(self.draft) DraftsPersistentContainer.shared.viewContext.delete(self.draft)
} }
DraftsManager.save() DraftsPersistentContainer.shared.save()
self.draft = draft self.draft = newDraft
} }
func onDisappear() { func onDisappear() {
if !draft.hasContent { if !draft.hasContent || didPostSuccessfully {
DraftsManager.shared.remove(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
} }
DraftsManager.save() DraftsPersistentContainer.shared.save()
} }
func toggleContentWarning() { func toggleContentWarning() {
@ -284,7 +306,7 @@ public final class ComposeController: ViewController {
.listRowBackground(config.backgroundColor) .listRowBackground(config.backgroundColor)
} }
HeaderView() HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8)) .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor) .listRowBackground(config.backgroundColor)
@ -329,11 +351,19 @@ public final class ComposeController: ViewController {
// otherwise all Buttons in the nav bar are made semibold // otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular)) .font(.system(size: 17, weight: .regular))
} }
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
}
} }
@ViewBuilder @ViewBuilder
private var postButton: some View { private var postButton: some View {
if draft.hasContent { if draft.hasContent || !controller.config.allowSwitchingDrafts {
Button(action: controller.postStatus) { Button(action: controller.postStatus) {
Text("Post") Text("Post")
} }

View File

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

View File

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

View File

@ -0,0 +1,70 @@
//
// Draft.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import Pachyderm
@objc
public class Draft: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Draft> {
return NSFetchRequest<Draft>(entityName: "Draft")
}
@nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest<Draft> {
let req = NSFetchRequest<Draft>(entityName: "Draft")
req.predicate = NSPredicate(format: "id = %@", id as NSUUID)
return req
}
@NSManaged public var accountID: String
@NSManaged public var contentWarning: String
@NSManaged public var contentWarningEnabled: Bool
@NSManaged public var id: UUID
@NSManaged public var initialText: String
@NSManaged public var inReplyToID: String?
@NSManaged public var lastModified: Date
@NSManaged public var localOnly: Bool
@NSManaged public var text: String
@NSManaged private var visibilityStr: String
@NSManaged internal var attachments: NSMutableOrderedSet
@NSManaged public var poll: Poll?
public var visibility: Visibility {
get {
Visibility(rawValue: visibilityStr) ?? .public
}
set {
visibilityStr = newValue.rawValue
}
}
public var draftAttachments: [DraftAttachment] {
get {
attachments.array as! [DraftAttachment]
}
set {
attachments = NSMutableOrderedSet(array: newValue)
}
}
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
lastModified = Date()
}
}
extension Draft {
public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
attachments.count > 0 ||
poll?.hasContent == true
}
}

View File

@ -0,0 +1,270 @@
//
// DraftAttachment.swift
// CoreData
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import PencilKit
import UniformTypeIdentifiers
import Photos
import InstanceFeatures
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
@objc
public final class DraftAttachment: NSManagedObject, Identifiable {
@NSManaged internal var assetID: String?
@NSManaged public var attachmentDescription: String
@NSManaged internal private(set) var drawingData: Data?
@NSManaged public var fileURL: URL?
@NSManaged internal var fileType: String?
@NSManaged public var id: UUID
@NSManaged internal var draft: Draft
public var drawing: PKDrawing? {
get {
if let drawingData,
let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) {
return drawing
} else {
return nil
}
}
set {
drawingData = try! encoder.encode(newValue)
}
}
public var data: AttachmentData {
if let assetID {
return .asset(assetID)
} else if let drawing {
return .drawing(drawing)
} else if let fileURL, let fileType {
return .file(fileURL, UTType(fileType)!)
} else {
fatalError()
}
}
public enum AttachmentData {
case asset(String)
case drawing(PKDrawing)
case file(URL, UTType)
}
}
extension DraftAttachment {
var type: AttachmentType {
if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
return .unknown
}
switch asset.mediaType {
case .image:
return .image
case .video:
return .video
default:
return .unknown
}
} else if drawingData != nil {
return .image
} else if let fileType,
let type = UTType(fileType) {
if type.conforms(to: .image) {
return .image
} else if type.conforms(to: .movie) {
return .video
} else {
return .unknown
}
} else {
return .unknown
}
}
enum AttachmentType {
case image, video, unknown
}
}
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
private let jpegType = UTType.jpeg.identifier
private let pngType = UTType.png.identifier
private let mp4Type = UTType.mpeg4Movie.identifier
private let quickTimeType = UTType.quickTimeMovie.identifier
private let gifType = UTType.gif.identifier
extension DraftAttachment: NSItemProviderReading {
public static var readableTypeIdentifiersForItemProvider: [String] {
// todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType]
}
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
attachment.fileType = typeIdentifier
attachment.attachmentDescription = ""
return attachment
}
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
let directoryURL = containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type)
try data.write(to: attachmentURL)
return attachmentURL
}
}
// MARK: Exporting
extension DraftAttachment {
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
completion(.failure(.noAsset))
return
}
if asset.mediaType == .image {
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .none
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in
guard let data, let dataUTI else {
completion(.failure(.missingAssetData))
return
}
let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
options.version = .current
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession {
Self.exportVideoData(session: exportSession, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
completion(.failure(.noVideoExportSession))
}
}
} else {
completion(.failure(.unknownAssetType))
}
} else if let drawingData {
guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else {
completion(.failure(.loadingDrawing))
return
}
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, .png)))
} else if let fileURL, let fileType {
let type = UTType(fileType)!
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: fileURL)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
completion(.failure(.noVideoExportSession))
return
}
Self.exportVideoData(session: session, completion: completion)
} else {
let fileData: Data
do {
fileData = try Data(contentsOf: fileURL)
} catch {
completion(.failure(.loadingData))
return
}
if type.conforms(to: .image) {
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
completion(.success(result))
} else {
completion(.success((fileData, type)))
}
}
}
}
private static func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
guard !skipAllConversion else {
return (data, type)
}
var data = data
var type = type
if type != .png && type != .jpeg,
let image = UIImage(data: data) {
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
type = .jpeg
}
}
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))
return
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}
}
}
enum ExportError: Error {
case noAsset
case unknownAssetType
case missingAssetData
case videoExport(Error)
case noVideoExportSession
case loadingDrawing
case loadingData
}
}

View File

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

View File

@ -0,0 +1,126 @@
//
// DraftsPersistentContainer.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import CoreData
import OSLog
import Pachyderm
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
public class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer()
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private var lastHistoryToken: NSPersistentHistoryToken!
init() {
super.init(name: "Drafts", managedObjectModel: DraftsPersistentContainer.managedObjectModel)
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
let documentsURL = containerURL.appendingPathComponent("Documents")
let storeDesc = NSPersistentStoreDescription(url: documentsURL.appendingPathComponent("drafts").appendingPathExtension("sqlite"))
storeDesc.type = NSSQLiteStoreType
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
persistentStoreDescriptions = [
storeDesc
]
loadPersistentStores { _, error in
if let error {
fatalError("Loading persistent store: \(error)")
}
}
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
lastHistoryToken = persistentStoreCoordinator.currentPersistentHistoryToken(fromStores: nil)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges(_:)), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
}
public func save() {
guard viewContext.hasChanges else {
return
}
do {
try viewContext.save()
} catch {
logger.error("Failed to save: \(String(describing: error))")
}
}
public func migrate(from url: URL, completion: @escaping (Result<(), any Error>) -> Void) {
performBackgroundTask { context in
let result = DraftsMigrator.migrate(from: url, to: context)
completion(result)
try! context.save()
}
}
public func getDraft(id: UUID) -> Draft? {
let req = Draft.fetchRequest(id: id)
return try? viewContext.fetch(req).first
}
public func createDraft(
accountID: String,
text: String,
contentWarning: String,
inReplyToID: String?,
visibility: Visibility,
localOnly: Bool
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
draft.text = text
draft.initialText = text
draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.localOnly = localOnly
save()
return draft
}
@objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return
}
// todo: should this be on a background context?
let context = viewContext
context.perform {
let predicate = NSPredicate(format: "(%@ < token) AND (token <= %@)", self.lastHistoryToken, newHistoryToken)
let historyRequest = NSPersistentHistoryTransaction.fetchRequest!
historyRequest.predicate = predicate
let request = NSPersistentHistoryChangeRequest.fetchHistory(withFetch: historyRequest)
if let result = try? context.execute(request) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] {
for transaction in transactions {
guard let userInfo = transaction.objectIDNotification().userInfo else {
continue
}
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context])
}
}
self.lastHistoryToken = newHistoryToken
}
}
}

View File

@ -0,0 +1,44 @@
//
// Poll.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class Poll: NSManagedObject {
@NSManaged public var duration: TimeInterval
@NSManaged public var multiple: Bool
@NSManaged public var draft: Draft
@NSManaged public var options: NSMutableOrderedSet
init(context: NSManagedObjectContext) {
super.init(entity: context.persistentStoreCoordinator!.managedObjectModel.entitiesByName["Poll"]!, insertInto: context)
self.multiple = false
self.duration = 24 * 60 * 60 // 1 day
self.options = [
PollOption(context: context),
PollOption(context: context),
]
}
public var pollOptions: [PollOption] {
get {
options.array as! [PollOption]
}
set {
options = NSMutableOrderedSet(array: newValue)
}
}
}
extension Poll {
public var hasContent: Bool {
pollOptions.allSatisfy { !$0.text.isEmpty }
}
}

View File

@ -0,0 +1,21 @@
//
// PollOption.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class PollOption: NSManagedObject, Identifiable {
public var id: NSManagedObjectID {
objectID
}
@NSManaged public var text: String
@NSManaged public var poll: Poll
}

View File

@ -0,0 +1,255 @@
//
// DraftsMigrator.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import OSLog
import UniformTypeIdentifiers
import Pachyderm
import PencilKit
import CoreData
struct DraftsMigrator {
private init() {}
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsMigrator")
private static let decoder = PropertyListDecoder()
static func migrate(from url: URL, to context: NSManagedObjectContext) -> Result<(), any Error> {
do {
let data = try Data(contentsOf: url)
let container = try decoder.decode(DraftsContainer.self, from: data)
for old in container.drafts.values {
let new = Draft(context: context)
new.id = old.id
new.lastModified = old.lastModified
new.accountID = old.accountID
new.text = old.text
new.contentWarningEnabled = old.contentWarningEnabled
new.contentWarning = old.contentWarning
new.inReplyToID = old.inReplyToID
new.visibility = old.visibility
new.localOnly = old.localOnly
new.initialText = old.initialText
if let oldPoll = old.poll {
let newPoll = Poll(context: context)
newPoll.draft = new
new.poll = newPoll
newPoll.multiple = oldPoll.multiple
newPoll.duration = oldPoll.duration
for oldOption in oldPoll.options {
let newOption = PollOption(context: context)
newOption.text = oldOption.text
newOption.poll = newPoll
newPoll.options.add(newOption)
}
}
for oldAttachment in old.attachments {
let newAttachment = DraftAttachment(context: context)
newAttachment.draft = new
new.attachments.add(newAttachment)
newAttachment.id = oldAttachment.id
newAttachment.attachmentDescription = oldAttachment.attachmentDescription
switch oldAttachment.data {
case .asset(let assetID):
newAttachment.assetID = assetID
case .image(let data, originalType: let type):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: type)
newAttachment.fileType = type.identifier
case .video(_):
fatalError("unreachable, video attachments weren't encodable")
case .drawing(let drawing):
newAttachment.drawing = drawing
case .gif(let data):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: .gif)
newAttachment.fileType = UTType.gif.identifier
}
}
}
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Error migrating: \(String(describing: error))")
return .failure(error)
}
return .success(())
}
// MARK: Supporting Types
struct DraftsContainer: Decodable {
let drafts: [UUID: OldDraft]
init(drafts: [UUID: OldDraft]) {
self.drafts = drafts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft)
}
enum CodingKeys: CodingKey {
case drafts
}
}
// a container that always succeeds at decoding
// so if a single draft can't be decoded, we don't lose all drafts
struct SafeDraft: Decodable {
let draft: OldDraft?
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.draft = try? container.decode(OldDraft.self)
}
}
struct OldDraft: Decodable {
let id: UUID
let lastModified: Date
let accountID: String
let text: String
let contentWarningEnabled: Bool
let contentWarning: String
let attachments: [OldDraftAttachment]
let inReplyToID: String?
let visibility: Visibility
let poll: OldPoll?
let localOnly: Bool
let initialText: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
self.accountID = try container.decode(String.self, forKey: .accountID)
self.text = try container.decode(String.self, forKey: .text)
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
self.attachments = try container.decode([OldDraftAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
self.poll = try container.decode(OldPoll?.self, forKey: .poll)
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
self.initialText = try container.decode(String.self, forKey: .initialText)
}
enum CodingKeys: String, CodingKey {
case id
case lastModified
case accountID
case text
case contentWarningEnabled
case contentWarning
case attachments
case inReplyToID
case visibility
case poll
case localOnly
case initialText
}
}
struct OldDraftAttachment: Decodable {
let id: UUID
let data: OldDraftAttachmentData
let attachmentDescription: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.data = try container.decode(OldDraftAttachmentData.self, forKey: .data)
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
}
enum CodingKeys: String, CodingKey {
case id
case data
case attachmentDescription
}
}
enum OldDraftAttachmentData: Decodable {
case asset(String)
case image(Data, originalType: UTType)
case video(URL)
case drawing(PKDrawing)
case gif(Data)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .type) {
case "asset":
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
self = .asset(identifier)
case "image":
let data = try container.decode(Data.self, forKey: .imageData)
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
self = .image(data, originalType: type)
} else {
guard let image = UIImage(data: data) else {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
}
let jpegData = image.jpegData(compressionQuality: 1)!
self = .image(jpegData, originalType: .jpeg)
}
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
}
}
enum CodingKeys: CodingKey {
case type
case imageData
case imageType
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
}
}
struct OldPoll: Decodable {
let options: [OldPollOption]
let multiple: Bool
let duration: TimeInterval
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.options = try container.decode([OldPollOption].self, forKey: .options)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
}
enum CodingKeys: String, CodingKey {
case options
case multiple
case duration
}
}
struct OldPollOption: Decodable {
let text: String
init(from decoder: Decoder) throws {
self.text = try decoder.singleValueContainer().decode(String.self)
}
}
}

View File

@ -1,177 +0,0 @@
//
// Draft.swift
// ComposeUI
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import Combine
import Pachyderm
public class Draft: Codable, Identifiable, ObservableObject {
public let id: UUID
var lastModified: Date
@Published public var accountID: String
@Published public var text: String
@Published public var contentWarningEnabled: Bool
@Published public var contentWarning: String
@Published public var attachments: [DraftAttachment]
@Published public var inReplyToID: String?
@Published public var visibility: Visibility
@Published public var poll: Poll?
@Published public var localOnly: Bool
var initialText: String
public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
attachments.count > 0 ||
poll?.hasContent == true
}
public init(
accountID: String,
text: String,
contentWarning: String,
inReplyToID: String?,
visibility: Visibility,
localOnly: Bool
) {
self.id = UUID()
self.lastModified = Date()
self.accountID = accountID
self.text = text
self.contentWarning = contentWarning
self.contentWarningEnabled = !contentWarning.isEmpty
self.attachments = []
self.inReplyToID = inReplyToID
self.visibility = visibility
self.localOnly = localOnly
self.initialText = text
}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
self.accountID = try container.decode(String.self, forKey: .accountID)
self.text = try container.decode(String.self, forKey: .text)
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
self.attachments = try container.decode([DraftAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
self.poll = try container.decode(Poll?.self, forKey: .poll)
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
self.initialText = try container.decode(String.self, forKey: .initialText)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(lastModified, forKey: .lastModified)
try container.encode(accountID, forKey: .accountID)
try container.encode(text, forKey: .text)
try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled)
try container.encode(contentWarning, forKey: .contentWarning)
try container.encode(attachments, forKey: .attachments)
try container.encode(inReplyToID, forKey: .inReplyToID)
try container.encode(visibility, forKey: .visibility)
try container.encode(poll, forKey: .poll)
try container.encode(localOnly, forKey: .localOnly)
try container.encode(initialText, forKey: .initialText)
}
}
extension Draft: Equatable {
public static func ==(lhs: Draft, rhs: Draft) -> Bool {
return lhs.id == rhs.id
}
}
extension Draft {
enum CodingKeys: String, CodingKey {
case id
case lastModified
case accountID
case text
case contentWarningEnabled
case contentWarning
case attachments
case inReplyToID
case visibility
case poll
case localOnly
case initialText
}
}
extension Draft {
public class Poll: Codable, ObservableObject {
@Published public var options: [Option]
@Published public var multiple: Bool
@Published public var duration: TimeInterval
var hasContent: Bool {
options.contains { !$0.text.isEmpty }
}
public init() {
self.options = [Option(""), Option("")]
self.multiple = false
self.duration = 24 * 60 * 60 // 1 day
}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.options = try container.decode([Option].self, forKey: .options)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(options, forKey: .options)
try container.encode(multiple, forKey: .multiple)
try container.encode(duration, forKey: .duration)
}
private enum CodingKeys: String, CodingKey {
case options
case multiple
case duration
}
public class Option: Identifiable, Codable, ObservableObject {
public let id = UUID()
@Published public var text: String
init(_ text: String) {
self.text = text
}
public required init(from decoder: Decoder) throws {
self.text = try decoder.singleValueContainer().decode(String.self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(text)
}
}
}
}

View File

@ -1,117 +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 imageType = UTType.image.identifier
private let mp4Type = UTType.mpeg4Movie.identifier
private let quickTimeType = UTType.quickTimeMovie.identifier
private let dataType = UTType.data.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] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
}
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 let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let temporaryFileName = ProcessInfo().globallyUniqueString
let fileExt = type.preferredFilenameExtension!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt)
try data.write(to: temporaryFileURL)
return DraftAttachment(data: .video(temporaryFileURL))
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
return DraftAttachment(data: .video(url))
} else {
throw ItemProviderError.incompatibleTypeIdentifier
}
}
}

View File

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

View File

@ -0,0 +1,36 @@
//
// File.swift
//
//
// Created by Shadowfacts on 4/22/23.
//
import SwiftUI
struct TestView: View {
@State var manager = DraftsPersistentContainer()
var body: some View {
VStack {
Button("Add") {
let entity = TestEntity(context: manager.viewContext)
entity.id = UUID()
try! manager.viewContext.save()
}
InnerView()
.environment(\.managedObjectContext, manager.viewContext)
}
}
}
struct InnerView: View {
@FetchRequest(sortDescriptors: []) var results: FetchedResults<TestEntity>
var body: some View {
List {
ForEach(results) { result in
Text(result.id?.uuidString ?? "<nil>")
}
}
}
}

View File

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

View File

@ -14,6 +14,10 @@ struct CurrentAccountView: View {
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
var body: some View { var body: some View {
controller.currentAccountContainerView(AnyView(currentAccount))
}
private var currentAccount: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
AvatarImageView( AvatarImageView(
url: account?.avatar, url: account?.avatar,

View File

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

View File

@ -15,6 +15,7 @@ struct MainTextView: View {
@State private var hasFirstAppeared = false @State private var hasFirstAppeared = false
@State private var height: CGFloat? @State private var height: CGFloat?
@State private var updateSelection: ((UITextView) -> Void)?
private let minHeight: CGFloat = 150 private let minHeight: CGFloat = 150
private var effectiveHeight: CGFloat { height ?? minHeight } private var effectiveHeight: CGFloat { height ?? minHeight }
@ -34,7 +35,7 @@ struct MainTextView: View {
.accessibilityHidden(true) .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) .frame(height: effectiveHeight)
.onAppear(perform: becomeFirstResponderOnFirstAppearance) .onAppear(perform: becomeFirstResponderOnFirstAppearance)
@ -44,6 +45,11 @@ struct MainTextView: View {
if !hasFirstAppeared { if !hasFirstAppeared {
hasFirstAppeared = true hasFirstAppeared = true
controller.mainComposeTextViewBecomeFirstResponder = 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 text: String
@Binding var becomeFirstResponder: Bool @Binding var becomeFirstResponder: Bool
@Binding var updateSelection: ((UITextView) -> Void)?
let textDidChange: (UITextView) -> Void let textDidChange: (UITextView) -> Void
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
@ -85,6 +92,11 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
context.coordinator.text = $text 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 // wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size // the text view knows its new content size
DispatchQueue.main.async { DispatchQueue.main.async {

View File

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

View File

@ -83,7 +83,7 @@ public class Client {
completion(.failure(Error(request: request, type: .invalidResponse))) completion(.failure(Error(request: request, type: .invalidResponse)))
return return
} }
guard response.statusCode == 200 else { guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else {
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data) let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode) let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(Error(request: request, type: type))) completion(.failure(Error(request: request, type: type)))

View File

@ -38,6 +38,8 @@ extension Timeline {
request.queryParameters.append("local" => true) request.queryParameters.append("local" => true)
} }
request.range = range request.range = range
// 206 can happen when the timeline is being regenerated and therefore is incomplete
request.additionalAcceptableHTTPCodes = [206]
return request return request
} }
} }

View File

@ -13,6 +13,7 @@ public struct Request<ResultType: Decodable>: Sendable {
let endpoint: Endpoint let endpoint: Endpoint
let body: Body let body: Body
var queryParameters: [Parameter] var queryParameters: [Parameter]
var additionalAcceptableHTTPCodes: [Int] = []
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) { init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
self.method = method self.method = method

View File

@ -24,8 +24,8 @@ let package = Package(
.target( .target(
name: "TuskerComponents", name: "TuskerComponents",
dependencies: []), dependencies: []),
.testTarget( // .testTarget(
name: "TuskerComponentsTests", // name: "TuskerComponentsTests",
dependencies: ["TuskerComponents"]), // dependencies: ["TuskerComponents"]),
] ]
) )

View File

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

9
Packages/TuskerPreferences/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,28 @@
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "TuskerPreferences",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "TuskerPreferences",
targets: ["TuskerPreferences"]),
],
dependencies: [
.package(path: "../Pachyderm"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "TuskerPreferences",
dependencies: ["Pachyderm"]
),
]
)

View File

@ -1,6 +1,6 @@
// //
// AvatarStyle.swift // AvatarStyle.swift
// Tusker // TuskerPreferences
// //
// Created by Shadowfacts on 8/28/18. // Created by Shadowfacts on 8/28/18.
// Copyright © 2018 Shadowfacts. All rights reserved. // Copyright © 2018 Shadowfacts. All rights reserved.
@ -8,12 +8,12 @@
import UIKit import UIKit
enum AvatarStyle: String, Codable { public enum AvatarStyle: String, Codable {
case roundRect, circle case roundRect, circle
} }
extension AvatarStyle { extension AvatarStyle {
var cornerRadiusFraction: CGFloat { public var cornerRadiusFraction: CGFloat {
switch self { switch self {
case .roundRect: case .roundRect:
return 0.1 return 0.1
@ -22,7 +22,7 @@ extension AvatarStyle {
} }
} }
func cornerRadius(for view: UIView) -> CGFloat { public func cornerRadius(for view: UIView) -> CGFloat {
return cornerRadiusFraction * view.frame.width return cornerRadiusFraction * view.frame.width
} }
} }

View File

@ -1,6 +1,6 @@
// //
// ContentWarningCopyMode.swift // ContentWarningCopyMode.swift
// Tusker // TuskerPreferences
// //
// Created by Shadowfacts on 7/31/19. // Created by Shadowfacts on 7/31/19.
// Copyright © 2019 Shadowfacts. All rights reserved. // Copyright © 2019 Shadowfacts. All rights reserved.
@ -8,7 +8,7 @@
import Foundation import Foundation
enum ContentWarningCopyMode: String, Codable { public enum ContentWarningCopyMode: String, Codable {
case asIs // copy CW as-is case asIs // copy CW as-is
case prependRe // prepend 're: ' to the beginning of the CW, if it doesn't already have it case prependRe // prepend 're: ' to the beginning of the CW, if it doesn't already have it
case doNotCopy // don't copy CW at all case doNotCopy // don't copy CW at all

View File

@ -1,6 +1,6 @@
// //
// NotificationsMode.swift // NotificationsMode.swift
// Tusker // TuskerPreferences
// //
// Created by Shadowfacts on 9/14/19. // Created by Shadowfacts on 9/14/19.
// Copyright © 2019 Shadowfacts. All rights reserved. // Copyright © 2019 Shadowfacts. All rights reserved.
@ -8,13 +8,13 @@
import Foundation import Foundation
enum NotificationsMode: String, Codable, CaseIterable { public enum NotificationsMode: String, Codable, CaseIterable {
case allNotifications case allNotifications
case mentionsOnly case mentionsOnly
} }
extension NotificationsMode { extension NotificationsMode {
var displayName: String { public var displayName: String {
switch self { switch self {
case .allNotifications: case .allNotifications:
return NSLocalizedString("All Notifications", comment: "display all notifications mode") return NSLocalizedString("All Notifications", comment: "display all notifications mode")

View File

@ -9,6 +9,6 @@
import Foundation import Foundation
extension Notification.Name { extension Notification.Name {
static let preferencesChanged = Notification.Name("Tusker.preferencesChanged") public static let preferencesChanged = Notification.Name("Tusker.preferencesChanged")
static let themePreferenceChanged = Notification.Name("Tusker.themePreferenceChanged") public static let themePreferenceChanged = Notification.Name("Tusker.themePreferenceChanged")
} }

View File

@ -1,6 +1,6 @@
// //
// Preferences.swift // Preferences.swift
// Tusker // TuskerPreferences
// //
// Created by Shadowfacts on 8/28/18. // Created by Shadowfacts on 8/28/18.
// Copyright © 2018 Shadowfacts. All rights reserved. // Copyright © 2018 Shadowfacts. All rights reserved.
@ -10,20 +10,21 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
class Preferences: Codable, ObservableObject { public class Preferences: Codable, ObservableObject {
static var shared: Preferences = load() public static var shared: Preferences = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var archiveURL = Preferences.documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
static func save() { public static func save() {
let encoder = PropertyListEncoder() let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared) let data = try? encoder.encode(shared)
try? data?.write(to: archiveURL, options: .noFileProtection) try? data?.write(to: archiveURL, options: .noFileProtection)
} }
static func load() -> Preferences { public static func load() -> Preferences {
let decoder = PropertyListDecoder() let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL), if let data = try? Data(contentsOf: archiveURL),
let preferences = try? decoder.decode(Preferences.self, from: data) { let preferences = try? decoder.decode(Preferences.self, from: data) {
@ -32,9 +33,20 @@ class Preferences: Codable, ObservableObject {
return Preferences() return Preferences()
} }
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() {} private init() {}
required init(from decoder: Decoder) throws { public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme) self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
@ -91,7 +103,7 @@ class Preferences: Codable, ObservableObject {
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
} }
func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme) try container.encode(theme, forKey: .theme)
@ -145,29 +157,29 @@ class Preferences: Codable, ObservableObject {
} }
// MARK: Appearance // MARK: Appearance
@Published var theme = UIUserInterfaceStyle.unspecified @Published public var theme = UIUserInterfaceStyle.unspecified
@Published var pureBlackDarkMode = true @Published public var pureBlackDarkMode = true
@Published var accentColor = AccentColor.default @Published public var accentColor = AccentColor.default
@Published var avatarStyle = AvatarStyle.roundRect @Published public var avatarStyle = AvatarStyle.roundRect
@Published var hideCustomEmojiInUsernames = false @Published public var hideCustomEmojiInUsernames = false
@Published var showIsStatusReplyIcon = false @Published public var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false @Published public var alwaysShowStatusVisibilityIcon = false
@Published var hideActionsInTimeline = false @Published public var hideActionsInTimeline = false
@Published var showLinkPreviews = true @Published public var showLinkPreviews = true
@Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog] @Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
// MARK: Composing // MARK: Composing
@Published var defaultPostVisibility = Visibility.public @Published public var defaultPostVisibility = Visibility.public
@Published var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published var automaticallySaveDrafts = true @Published public var automaticallySaveDrafts = true
@Published var requireAttachmentDescriptions = false @Published public var requireAttachmentDescriptions = false
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published var mentionReblogger = false @Published public var mentionReblogger = false
@Published var useTwitterKeyboard = false @Published public var useTwitterKeyboard = false
// MARK: Media // MARK: Media
@Published var attachmentBlurMode = AttachmentBlurMode.useStatusSetting { @Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
didSet { didSet {
if attachmentBlurMode == .always { if attachmentBlurMode == .always {
blurMediaBehindContentWarning = true blurMediaBehindContentWarning = true
@ -176,38 +188,38 @@ class Preferences: Codable, ObservableObject {
} }
} }
} }
@Published var blurMediaBehindContentWarning = true @Published public var blurMediaBehindContentWarning = true
@Published var automaticallyPlayGifs = true @Published public var automaticallyPlayGifs = true
@Published var showUncroppedMediaInline = true @Published public var showUncroppedMediaInline = true
@Published var showAttachmentBadges = true @Published public var showAttachmentBadges = true
// MARK: Behavior // MARK: Behavior
@Published var openLinksInApps = true @Published public var openLinksInApps = true
@Published var useInAppSafari = true @Published public var useInAppSafari = true
@Published var inAppSafariAutomaticReaderMode = false @Published public var inAppSafariAutomaticReaderMode = false
@Published var expandAllContentWarnings = false @Published public var expandAllContentWarnings = false
@Published var collapseLongPosts = true @Published public var collapseLongPosts = true
@Published var oppositeCollapseKeywords: [String] = [] @Published public var oppositeCollapseKeywords: [String] = []
@Published var confirmBeforeReblog = false @Published public var confirmBeforeReblog = false
@Published var timelineStateRestoration = true @Published public var timelineStateRestoration = true
@Published var timelineSyncMode = TimelineSyncMode.icloud @Published public var timelineSyncMode = TimelineSyncMode.icloud
@Published var hideReblogsInTimelines = false @Published public var hideReblogsInTimelines = false
@Published var hideRepliesInTimelines = false @Published public var hideRepliesInTimelines = false
// MARK: Digital Wellness // MARK: Digital Wellness
@Published var showFavoriteAndReblogCounts = true @Published public var showFavoriteAndReblogCounts = true
@Published var defaultNotificationsMode = NotificationsMode.allNotifications @Published public var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false @Published public var grayscaleImages = false
@Published var disableInfiniteScrolling = false @Published public var disableInfiniteScrolling = false
@Published var hideTrends = false @Published public var hideTrends = false
// MARK: Advanced // MARK: Advanced
@Published var statusContentType: StatusContentType = .plain @Published public var statusContentType: StatusContentType = .plain
@Published var reportErrorsAutomatically = true @Published public var reportErrorsAutomatically = true
// MARK: // MARK:
@Published var hasShownLocalTimelineDescription = false @Published public var hasShownLocalTimelineDescription = false
@Published var hasShownFederatedTimelineDescription = false @Published public var hasShownFederatedTimelineDescription = false
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case theme case theme
@ -264,13 +276,13 @@ class Preferences: Codable, ObservableObject {
} }
extension Preferences { extension Preferences {
enum ReplyVisibility: Codable, Hashable, CaseIterable { public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost case sameAsPost
case visibility(Visibility) case visibility(Visibility)
static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } public static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
var resolved: Visibility { public var resolved: Visibility {
switch self { switch self {
case .sameAsPost: case .sameAsPost:
return Preferences.shared.defaultPostVisibility return Preferences.shared.defaultPostVisibility
@ -279,7 +291,7 @@ extension Preferences {
} }
} }
var displayName: String { public var displayName: String {
switch self { switch self {
case .sameAsPost: case .sameAsPost:
return "Same as Default" return "Same as Default"
@ -288,7 +300,7 @@ extension Preferences {
} }
} }
var imageName: String? { public var imageName: String? {
switch self { switch self {
case .sameAsPost: case .sameAsPost:
return nil return nil
@ -300,12 +312,12 @@ extension Preferences {
} }
extension Preferences { extension Preferences {
enum AttachmentBlurMode: Codable, Hashable, CaseIterable { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting case useStatusSetting
case always case always
case never case never
var displayName: String { public var displayName: String {
switch self { switch self {
case .useStatusSetting: case .useStatusSetting:
return "Default" return "Default"
@ -321,7 +333,7 @@ extension Preferences {
extension UIUserInterfaceStyle: Codable {} extension UIUserInterfaceStyle: Codable {}
extension Preferences { extension Preferences {
enum AccentColor: String, Codable, CaseIterable { public enum AccentColor: String, Codable, CaseIterable {
case `default` case `default`
case purple case purple
case indigo case indigo
@ -336,7 +348,7 @@ extension Preferences {
case pink case pink
// case brown // case brown
var color: UIColor? { public var color: UIColor? {
switch self { switch self {
case .default: case .default:
return nil return nil
@ -367,7 +379,7 @@ extension Preferences {
} }
} }
var name: String { public var name: String {
switch self { switch self {
case .default: case .default:
return "Default" return "Default"
@ -401,7 +413,7 @@ extension Preferences {
} }
extension Preferences { extension Preferences {
enum TimelineSyncMode: String, Codable { public enum TimelineSyncMode: String, Codable {
case mastodon case mastodon
case icloud case icloud
} }

View File

@ -0,0 +1,53 @@
//
// StatusSwipeAction.swift
// Tusker
//
// Created by Shadowfacts on 11/26/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
case reply
case favorite
case reblog
case share
case bookmark
case openInSafari
public var displayName: String {
switch self {
case .reply:
return "Reply"
case .favorite:
return "Favorite"
case .reblog:
return "Reblog"
case .share:
return "Share"
case .bookmark:
return "Bookmark"
case .openInSafari:
return "Open in Safari"
}
}
public var systemImageName: String {
switch self {
case .reply:
return "arrowshape.turn.up.left.fill"
case .favorite:
return "star.fill"
case .reblog:
return "repeat"
case .share:
return "square.and.arrow.up"
case .bookmark:
return "bookmark.fill"
case .openInSafari:
return "safari"
}
}
}

View File

@ -24,8 +24,8 @@ let package = Package(
.target( .target(
name: "UserAccounts", name: "UserAccounts",
dependencies: ["Pachyderm"]), dependencies: ["Pachyderm"]),
.testTarget( // .testTarget(
name: "UserAccountsTests", // name: "UserAccountsTests",
dependencies: ["UserAccounts"]), // dependencies: ["UserAccounts"]),
] ]
) )

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
public struct UserAccountInfo: Equatable, Hashable { public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public let id: String public let id: String
public let instanceURL: URL public let instanceURL: URL
public let clientID: String public let clientID: String

View File

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

25
ShareExtension/Info.plist Normal file
View File

@ -0,0 +1,25 @@
<?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>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>4</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,140 @@
//
// 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 attributedContent = inputItem.attributedContentText {
return ("\n\n\(attributedContent.string)", [])
} else {
let attachments = await withTaskGroup(of: DraftAttachment?.self, returning: [DraftAttachment].self) { group in
for provider in inputItem.attachments! {
group.addTask { @MainActor in
await self.getObject(from: provider)
}
}
return await group.reduce(into: [], { partialResult, result in
if let result {
partialResult.append(result)
}
})
}
return ("", attachments)
}
}
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
}
}

View File

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

View File

@ -53,7 +53,7 @@
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; }; D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; };
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; }; D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; };
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; }; D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; };
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */; }; D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75872932DB6000C0B37F /* StatusSwipeActions.swift */; };
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */; }; D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */; };
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */; }; D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */; };
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; }; D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
@ -88,7 +88,6 @@
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */; }; D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */; };
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */; }; D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */; };
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943123A5466600D38C68 /* SelectableTableViewCell.swift */; }; D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943123A5466600D38C68 /* SelectableTableViewCell.swift */; };
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */; };
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */; }; D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */; };
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; }; D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; }; D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; };
@ -152,8 +151,6 @@
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; }; D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; };
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; }; D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; };
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; };
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; };
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; }; D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; }; D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; };
@ -223,6 +220,17 @@
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */; }; D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */; };
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; }; D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; }; 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 */; }; D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; };
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; }; D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; };
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; }; D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; };
@ -257,7 +265,6 @@
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; }; D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; };
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; }; D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; };
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; }; D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; };
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = D6BD395829B64426005FFD2B /* ComposeUI */; }; D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = D6BD395829B64426005FFD2B /* ComposeUI */; };
@ -281,6 +288,7 @@
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; }; D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; };
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D6CA6ED129EF6091003EC5DF /* TuskerPreferences */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; }; D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
@ -299,8 +307,6 @@
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; }; D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
@ -340,6 +346,13 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
D6A4531B29EF64BA00032932 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
proxyType = 1;
remoteGlobalIDString = D6A4531229EF64BA00032932;
remoteInfo = ShareExtension;
};
D6D4DDE1212518A200E1C4BB /* PBXContainerItemProxy */ = { D6D4DDE1212518A200E1C4BB /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */; containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
@ -370,6 +383,7 @@
dstPath = ""; dstPath = "";
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */,
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */, D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
@ -433,7 +447,7 @@
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; }; D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; }; D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; }; D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSwipeAction.swift; sourceTree = "<group>"; }; D61F75872932DB6000C0B37F /* StatusSwipeActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSwipeActions.swift; sourceTree = "<group>"; };
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionsPrefsView.swift; sourceTree = "<group>"; }; D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionsPrefsView.swift; sourceTree = "<group>"; };
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationTableViewCell.swift; sourceTree = "<group>"; }; D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationTableViewCell.swift; sourceTree = "<group>"; };
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; }; D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
@ -468,7 +482,6 @@
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = "<group>"; }; D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = "<group>"; };
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableViewCell.swift; sourceTree = "<group>"; }; D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableViewCell.swift; sourceTree = "<group>"; };
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableTableViewCell.swift; sourceTree = "<group>"; }; D627943123A5466600D38C68 /* SelectableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableTableViewCell.swift; sourceTree = "<group>"; };
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigableTableViewCell.swift; sourceTree = "<group>"; };
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = "<group>"; }; D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = "<group>"; };
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; }; D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; }; D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; };
@ -533,8 +546,6 @@
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = "<group>"; }; D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = "<group>"; };
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = "<group>"; }; D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = "<group>"; };
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = "<group>"; }; D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = "<group>"; };
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = "<group>"; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; }; D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; };
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = "<group>"; }; D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = "<group>"; };
D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = "<group>"; }; D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = "<group>"; };
@ -605,6 +616,14 @@
D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FollowNotificationGroupTableViewCell.xib; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; };
@ -639,7 +658,6 @@
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; }; D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; }; D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; };
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; }; D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; };
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
D6BD395729B6441F005FFD2B /* ComposeUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ComposeUI; path = Packages/ComposeUI; sourceTree = "<group>"; }; D6BD395729B6441F005FFD2B /* ComposeUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ComposeUI; path = Packages/ComposeUI; sourceTree = "<group>"; };
@ -664,6 +682,7 @@
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; }; D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerPreferences; path = Packages/TuskerPreferences; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; }; D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; }; D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
@ -689,8 +708,6 @@
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; }; D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
@ -733,6 +750,18 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase 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 */ = { D6D4DDC9212518A000E1C4BB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -745,6 +774,7 @@
D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */, D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */, D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */, D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
@ -1183,12 +1213,7 @@
D663626021360A9600C9CBA2 /* Preferences */ = { D663626021360A9600C9CBA2 /* Preferences */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D663626121360B1900C9CBA2 /* Preferences.swift */, D61F75872932DB6000C0B37F /* StatusSwipeActions.swift */,
D663626321360D2300C9CBA2 /* AvatarStyle.swift */,
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */,
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */,
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */,
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */,
D6D94954298963A900C59229 /* Colors.swift */, D6D94954298963A900C59229 /* Colors.swift */,
); );
path = Preferences; path = Preferences;
@ -1278,6 +1303,20 @@
path = "Status Action Account List"; path = "Status Action Account List";
sourceTree = "<group>"; 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 */ = { D6A4DCC92553666600D9DE31 /* Fast Account Switcher */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1344,7 +1383,6 @@
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */, D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */, D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */, D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */, D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */, D620483723D38190008A63EF /* StatusContentTextView.swift */,
@ -1418,10 +1456,12 @@
D6FA94DF29B52891006AAC51 /* InstanceFeatures */, D6FA94DF29B52891006AAC51 /* InstanceFeatures */,
D6BD395C29B789D5005FFD2B /* TuskerComponents */, D6BD395C29B789D5005FFD2B /* TuskerComponents */,
D6BD395729B6441F005FFD2B /* ComposeUI */, D6BD395729B6441F005FFD2B /* ComposeUI */,
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDE3212518A200E1C4BB /* TuskerTests */,
D6D4DDEE212518A200E1C4BB /* TuskerUITests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
D6E343A9265AAD6B00C4AA01 /* OpenInTusker */, D6E343A9265AAD6B00C4AA01 /* OpenInTusker */,
D6A4531429EF64BA00032932 /* ShareExtension */,
D6D4DDCD212518A000E1C4BB /* Products */, D6D4DDCD212518A000E1C4BB /* Products */,
D65A37F221472F300087646E /* Frameworks */, D65A37F221472F300087646E /* Frameworks */,
); );
@ -1434,6 +1474,7 @@
D6D4DDE0212518A200E1C4BB /* TuskerTests.xctest */, D6D4DDE0212518A200E1C4BB /* TuskerTests.xctest */,
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */, D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */,
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */, D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */,
D6A4531329EF64BA00032932 /* ShareExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1592,6 +1633,30 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget 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 */ = { D6D4DDCB212518A000E1C4BB /* Tusker */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = D6D4DDF4212518A200E1C4BB /* Build configuration list for PBXNativeTarget "Tusker" */; buildConfigurationList = D6D4DDF4212518A200E1C4BB /* Build configuration list for PBXNativeTarget "Tusker" */;
@ -1608,6 +1673,7 @@
); );
dependencies = ( dependencies = (
D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */, D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */,
D6A4531C29EF64BA00032932 /* PBXTargetDependency */,
); );
name = Tusker; name = Tusker;
packageProductDependencies = ( packageProductDependencies = (
@ -1622,6 +1688,7 @@
D6FA94E029B52898006AAC51 /* InstanceFeatures */, D6FA94E029B52898006AAC51 /* InstanceFeatures */,
D635237029B78A7D009ED5E7 /* TuskerComponents */, D635237029B78A7D009ED5E7 /* TuskerComponents */,
D6BD395829B64426005FFD2B /* ComposeUI */, D6BD395829B64426005FFD2B /* ComposeUI */,
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
); );
productName = Tusker; productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1689,10 +1756,13 @@
D6D4DDC4212518A000E1C4BB /* Project object */ = { D6D4DDC4212518A000E1C4BB /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1250; LastSwiftUpdateCheck = 1430;
LastUpgradeCheck = 1400; LastUpgradeCheck = 1400;
ORGANIZATIONNAME = Shadowfacts; ORGANIZATIONNAME = Shadowfacts;
TargetAttributes = { TargetAttributes = {
D6A4531229EF64BA00032932 = {
CreatedOnToolsVersion = 14.3;
};
D6D4DDCB212518A000E1C4BB = { D6D4DDCB212518A000E1C4BB = {
CreatedOnToolsVersion = 10.0; CreatedOnToolsVersion = 10.0;
LastSwiftMigration = 1420; LastSwiftMigration = 1420;
@ -1741,11 +1811,20 @@
D6D4DDDF212518A200E1C4BB /* TuskerTests */, D6D4DDDF212518A200E1C4BB /* TuskerTests */,
D6D4DDEA212518A200E1C4BB /* TuskerUITests */, D6D4DDEA212518A200E1C4BB /* TuskerUITests */,
D6E343A7265AAD6B00C4AA01 /* OpenInTusker */, D6E343A7265AAD6B00C4AA01 /* OpenInTusker */,
D6A4531229EF64BA00032932 /* ShareExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
D6A4531129EF64BA00032932 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D6D4DDCA212518A000E1C4BB /* Resources */ = { D6D4DDCA212518A000E1C4BB /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -1845,12 +1924,22 @@
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase 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 */ = { D6D4DDC8212518A000E1C4BB /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */, D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */, D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */, D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
@ -1964,11 +2053,9 @@
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */, D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */, D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */, D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */, D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */, D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
@ -2014,15 +2101,13 @@
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */, D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */, D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */, D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */, D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */, D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
@ -2090,7 +2175,6 @@
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */, D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */, D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */, D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */, D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */, D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
@ -2169,6 +2253,11 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
D6A4531C29EF64BA00032932 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D6A4531229EF64BA00032932 /* ShareExtension */;
targetProxy = D6A4531B29EF64BA00032932 /* PBXContainerItemProxy */;
};
D6D4DDE2212518A200E1C4BB /* PBXTargetDependency */ = { D6D4DDE2212518A200E1C4BB /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = D6D4DDCB212518A000E1C4BB /* Tusker */; target = D6D4DDCB212518A000E1C4BB /* Tusker */;
@ -2188,6 +2277,14 @@
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
D6A4531729EF64BA00032932 /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
D6A4531829EF64BA00032932 /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */ = { D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup; isa = PBXVariantGroup;
children = ( children = (
@ -2283,7 +2380,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77; CURRENT_PROJECT_VERSION = 80;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2349,7 +2446,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77; CURRENT_PROJECT_VERSION = 80;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2368,6 +2465,93 @@
}; };
name = Dist; 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 = 80;
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 = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).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 = 80;
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 = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).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 = 80;
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 = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).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 */ = { D6D4DDF2212518A200E1C4BB /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = D6D706A829498C82000827ED /* Tusker.xcconfig */; baseConfigurationReference = D6D706A829498C82000827ED /* Tusker.xcconfig */;
@ -2501,7 +2685,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77; CURRENT_PROJECT_VERSION = 80;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2532,7 +2716,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77; CURRENT_PROJECT_VERSION = 80;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2638,7 +2822,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77; CURRENT_PROJECT_VERSION = 80;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2664,7 +2848,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77; CURRENT_PROJECT_VERSION = 80;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2686,6 +2870,16 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList 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" */ = { D6D4DDC7212518A000E1C4BB /* Build configuration list for PBXProject "Tusker" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@ -2810,6 +3004,26 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Pachyderm; 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 */ = { D6B0026D29B5248800C70BE2 /* UserAccounts */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = UserAccounts; productName = UserAccounts;
@ -2822,6 +3036,10 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Duckable; productName = Duckable;
}; };
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */ = {
isa = XCSwiftPackageProductDependency;
productName = TuskerPreferences;
};
D6FA94E029B52898006AAC51 /* InstanceFeatures */ = { D6FA94E029B52898006AAC51 /* InstanceFeatures */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = InstanceFeatures; productName = InstanceFeatures;

View File

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

View File

@ -12,13 +12,16 @@ import OSLog
import Sentry import Sentry
import UserAccounts import UserAccounts
import ComposeUI import ComposeUI
import TuskerPreferences
typealias Preferences = TuskerPreferences.Preferences
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration") let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate")
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configureSentry() configureSentry()
swizzleStatusBar() swizzleStatusBar()
@ -49,14 +52,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
DispatchQueue.global(qos: .userInitiated).async {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") let oldPreferencesFile = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
if FileManager.default.fileExists(atPath: oldDraftsFile.path) { if FileManager.default.fileExists(atPath: oldPreferencesFile.path) {
if case .failure(let error) = DraftsManager.migrate(from: oldDraftsFile) { if case .failure(let error) = Preferences.migrate(from: oldPreferencesFile) {
SentrySDK.capture(error: error) SentrySDK.capture(error: error)
} }
} }
DispatchQueue.global(qos: .userInitiated).async {
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
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)
}
}
}
} }
return true return true
@ -94,6 +107,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return Preferences.shared.reportErrorsAutomatically ? event : nil return Preferences.shared.reportErrorsAutomatically ? event : nil
} }
} }
if let clazz = NSClassFromString("SentryInstallation"),
let objClazz = clazz as AnyObject as? NSObjectProtocol,
objClazz.responds(to: Selector(("id"))),
let id = objClazz.perform(Selector(("id"))).takeUnretainedValue() as? String {
logger.info("Initialized Sentry with installation/user ID: \(id)")
}
} }
override func buildMenu(with builder: UIMenuBuilder) { override func buildMenu(with builder: UIMenuBuilder) {

View File

@ -1,5 +1,5 @@
// //
// StatusSwipeAction.swift // StatusSwipeActions.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 11/26/22. // Created by Shadowfacts on 11/26/22.
@ -8,49 +8,9 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import TuskerPreferences
enum StatusSwipeAction: String, Codable, Hashable, CaseIterable { extension StatusSwipeAction {
case reply
case favorite
case reblog
case share
case bookmark
case openInSafari
var displayName: String {
switch self {
case .reply:
return "Reply"
case .favorite:
return "Favorite"
case .reblog:
return "Reblog"
case .share:
return "Share"
case .bookmark:
return "Bookmark"
case .openInSafari:
return "Open in Safari"
}
}
var systemImageName: String {
switch self {
case .reply:
return "arrowshape.turn.up.left.fill"
case .favorite:
return "star.fill"
case .reblog:
return "repeat"
case .share:
return "square.and.arrow.up"
case .bookmark:
return "bookmark.fill"
case .openInSafari:
return "safari"
}
}
func createAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? { func createAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
switch self { switch self {
case .reply: case .reply:

View File

@ -77,11 +77,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
} }
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
DraftsManager.save() DraftsPersistentContainer.shared.save()
if let window = window, if let window = window,
let nav = window.rootViewController as? UINavigationController, let nav = window.rootViewController as? UINavigationController,
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) scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
} }
} }

View File

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

View File

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

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import TuskerPreferences
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> { class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import TuskerPreferences
struct ComposingPrefsView: View { struct ComposingPrefsView: View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared

View File

@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import TuskerPreferences
struct SwipeActionsPrefsView: UIViewControllerRepresentable { struct SwipeActionsPrefsView: UIViewControllerRepresentable {
@Binding var selection: [StatusSwipeAction] @Binding var selection: [StatusSwipeAction]

View File

@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import TuskerPreferences
struct WellnessPrefsView: View { struct WellnessPrefsView: View {
@ObservedObject private var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared

View File

@ -176,13 +176,18 @@ extension MenuActionProvider {
guard let accountID = mastodonController.accountInfo?.id, guard let accountID = mastodonController.accountInfo?.id,
let account = mastodonController.account else { let account = mastodonController.account else {
// user is logged out // user is logged out
// i don't know why exactly a status wouldn't have a url, but apparently it happens, so:
if let url = status.url {
return [ return [
openInSafariAction(url: status.url!), openInSafariAction(url: url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source) self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
}) }),
] ]
} else {
return []
}
} }
let bookmarked = status.bookmarked ?? false let bookmarked = status.bookmarked ?? false

View File

@ -12,6 +12,7 @@ import Pachyderm
import OSLog import OSLog
import UserAccounts import UserAccounts
import ComposeUI import ComposeUI
import TuskerPreferences
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
@ -102,7 +103,7 @@ class UserActivityManager {
let uuid = UUID(uuidString: idStr) else { let uuid = UUID(uuidString: idStr) else {
return nil return nil
} }
return DraftsManager.shared.getBy(id: uuid) return DraftsPersistentContainer.shared.getDraft(id: uuid)
} }
static func getDuckedDraft(from activity: NSUserActivity) -> Draft? { static func getDuckedDraft(from activity: NSUserActivity) -> Draft? {
@ -110,7 +111,7 @@ class UserActivityManager {
let uuid = UUID(uuidString: idStr) else { let uuid = UUID(uuidString: idStr) else {
return nil return nil
} }
return DraftsManager.shared.getBy(id: uuid) return DraftsPersistentContainer.shared.getDraft(id: uuid)
} }
// MARK: - Check Notifications // MARK: - Check Notifications

View File

@ -16,6 +16,12 @@ class CachedImageView: UIImageView {
private var fetchTask: Task<Void, Error>? private var fetchTask: Task<Void, Error>?
private var blurHashTask: DispatchWorkItem? private var blurHashTask: DispatchWorkItem?
override var image: UIImage? {
didSet {
fetchTask?.cancel()
}
}
init(cache: ImageCache) { init(cache: ImageCache) {
self.cache = cache self.cache = cache
super.init(frame: .zero) super.init(frame: .zero)
@ -31,10 +37,10 @@ class CachedImageView: UIImageView {
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
func update(for url: URL?, blurhash: String? = nil) { func update(for url: URL?, blurhash: String? = nil, placeholder: UIImage? = nil) {
if url != self.url || (url != nil && self.image == nil) { if url != self.url || (url != nil && self.image == nil) {
self.url = url self.url = url
self.image = nil super.image = placeholder
updateBlurhash(blurhash, for: url) updateBlurhash(blurhash, for: url)
updateImage() updateImage()
} }
@ -82,7 +88,7 @@ class CachedImageView: UIImageView {
return return
} }
try Task.checkCancellation() try Task.checkCancellation()
self.image = transformedImage super.image = transformedImage
self.isGrayscale = Preferences.shared.grayscaleImages self.isGrayscale = Preferences.shared.grayscaleImages
self.blurHashTask?.cancel() self.blurHashTask?.cancel()
} }

View File

@ -1,13 +0,0 @@
//
// NavigableTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 12/14/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import Foundation
protocol NavigableTableViewCell {
var navigationDelegate: TuskerNavigationDelegate? { get }
}

View File

@ -20,6 +20,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 16, weight: .regular)) static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 16, weight: .regular))
static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle
private static let timelineReasonIconSize: CGFloat = 25
// MARK: Subviews // MARK: Subviews
private lazy var timelineReasonLabel = EmojiLabel().configure { private lazy var timelineReasonLabel = EmojiLabel().configure {
@ -27,8 +29,16 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.font = .preferredFont(forTextStyle: .body) $0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true $0.adjustsFontForContentSizeCategory = true
} }
private let timelineReasonIcon = UIImageView(image: reblogIcon).configure { private let timelineReasonIcon = CachedImageView(cache: .avatars).configure {
$0.image = reblogIcon
$0.contentMode = .scaleAspectFit
$0.layer.masksToBounds = true
$0.tintColor = .secondaryLabel $0.tintColor = .secondaryLabel
NSLayoutConstraint.activate([
// this needs to be lessThanOrEqualTo not just equalTo b/c otherwise intermediate layouts are broken
$0.heightAnchor.constraint(lessThanOrEqualToConstant: TimelineStatusCollectionViewCell.timelineReasonIconSize),
$0.widthAnchor.constraint(equalTo: $0.heightAnchor),
])
} }
private lazy var timelineReasonHStack = UIStackView(arrangedSubviews: [ private lazy var timelineReasonHStack = UIStackView(arrangedSubviews: [
timelineReasonIcon, timelineReasonIcon,
@ -304,6 +314,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4) mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4)
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 8) mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 8)
// when flipping between topToReblog and topToSelf constraints, the framework sometimes thinks both of them should be active simultaneously
// even though the code never does that; so let this one get broken temporarily
mainContainerTopToSelfConstraint.priority = .init(999)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4) mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6) mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6)
@ -546,7 +559,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
rebloggerID = status.account.id rebloggerID = status.account.id
hideTimelineReason = false hideTimelineReason = false
timelineReasonIcon.image = reblogIcon
updateRebloggerLabel(reblogger: status.account) updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus status = rebloggedStatus
} else { } else {
@ -604,6 +616,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
metaIndicatorsView.updateUI(status: status) metaIndicatorsView.updateUI(status: status)
timelineReasonIcon.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.timelineReasonIconSize
if let rebloggerID, if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger) updateRebloggerLabel(reblogger: reblogger)
@ -650,6 +663,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
private func updateRebloggerLabel(reblogger: AccountMO) { private func updateRebloggerLabel(reblogger: AccountMO) {
timelineReasonIcon.update(for: reblogger.avatar, placeholder: reblogIcon)
if Preferences.shared.hideCustomEmojiInUsernames { if Preferences.shared.hideCustomEmojiInUsernames {
timelineReasonLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged" timelineReasonLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
timelineReasonLabel.removeEmojis() timelineReasonLabel.removeEmojis()