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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -83,7 +83,7 @@ public class Client {
completion(.failure(Error(request: request, type: .invalidResponse)))
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 type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(Error(request: request, type: type)))

View File

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

View File

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

View File

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

View File

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

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
// Tusker
// TuskerPreferences
//
// Created by Shadowfacts on 8/28/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
@ -8,12 +8,12 @@
import UIKit
enum AvatarStyle: String, Codable {
public enum AvatarStyle: String, Codable {
case roundRect, circle
}
extension AvatarStyle {
var cornerRadiusFraction: CGFloat {
public var cornerRadiusFraction: CGFloat {
switch self {
case .roundRect:
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
}
}

View File

@ -1,6 +1,6 @@
//
// ContentWarningCopyMode.swift
// Tusker
// TuskerPreferences
//
// Created by Shadowfacts on 7/31/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
@ -8,7 +8,7 @@
import Foundation
enum ContentWarningCopyMode: String, Codable {
public enum ContentWarningCopyMode: String, Codable {
case asIs // copy CW as-is
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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -12,13 +12,16 @@ import OSLog
import Sentry
import UserAccounts
import ComposeUI
import TuskerPreferences
typealias Preferences = TuskerPreferences.Preferences
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate")
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configureSentry()
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 oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
if FileManager.default.fileExists(atPath: oldDraftsFile.path) {
if case .failure(let error) = DraftsManager.migrate(from: oldDraftsFile) {
let oldPreferencesFile = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
if FileManager.default.fileExists(atPath: oldPreferencesFile.path) {
if case .failure(let error) = Preferences.migrate(from: oldPreferencesFile) {
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
@ -94,6 +107,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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) {

View File

@ -1,5 +1,5 @@
//
// StatusSwipeAction.swift
// StatusSwipeActions.swift
// Tusker
//
// Created by Shadowfacts on 11/26/22.
@ -8,49 +8,9 @@
import UIKit
import Pachyderm
import TuskerPreferences
enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
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"
}
}
extension StatusSwipeAction {
func createAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
switch self {
case .reply:

View File

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

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).
Preferences.save()
DraftsManager.save()
DraftsPersistentContainer.shared.save()
}
func sceneDidBecomeActive(_ scene: UIScene) {
@ -101,7 +101,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
// This may occur due to temporary interruptions (ex. an incoming phone call).
Preferences.save()
DraftsManager.save()
DraftsPersistentContainer.shared.save()
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -176,13 +176,18 @@ extension MenuActionProvider {
guard let accountID = mastodonController.accountInfo?.id,
let account = mastodonController.account else {
// 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 [
openInSafariAction(url: status.url!),
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
})
}),
]
} else {
return []
}
}
let bookmarked = status.bookmarked ?? false

View File

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

View File

@ -16,6 +16,12 @@ class CachedImageView: UIImageView {
private var fetchTask: Task<Void, Error>?
private var blurHashTask: DispatchWorkItem?
override var image: UIImage? {
didSet {
fetchTask?.cancel()
}
}
init(cache: ImageCache) {
self.cache = cache
super.init(frame: .zero)
@ -31,10 +37,10 @@ class CachedImageView: UIImageView {
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) {
self.url = url
self.image = nil
super.image = placeholder
updateBlurhash(blurhash, for: url)
updateImage()
}
@ -82,7 +88,7 @@ class CachedImageView: UIImageView {
return
}
try Task.checkCancellation()
self.image = transformedImage
super.image = transformedImage
self.isGrayscale = Preferences.shared.grayscaleImages
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 contentParagraphStyle = HTMLConverter.defaultParagraphStyle
private static let timelineReasonIconSize: CGFloat = 25
// MARK: Subviews
private lazy var timelineReasonLabel = EmojiLabel().configure {
@ -27,8 +29,16 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.font = .preferredFont(forTextStyle: .body)
$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
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: [
timelineReasonIcon,
@ -304,6 +314,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4)
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)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6)
@ -546,7 +559,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
rebloggerID = status.account.id
hideTimelineReason = false
timelineReasonIcon.image = reblogIcon
updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus
} else {
@ -604,6 +616,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
metaIndicatorsView.updateUI(status: status)
timelineReasonIcon.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.timelineReasonIconSize
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger)
@ -650,6 +663,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
private func updateRebloggerLabel(reblogger: AccountMO) {
timelineReasonIcon.update(for: reblogger.avatar, placeholder: reblogIcon)
if Preferences.shared.hideCustomEmojiInUsernames {
timelineReasonLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
timelineReasonLabel.removeEmojis()