diff --git a/Packages/ComposeUI/.gitignore b/Packages/ComposeUI/.gitignore
new file mode 100644
index 00000000..3b298120
--- /dev/null
+++ b/Packages/ComposeUI/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
+DerivedData/
+.swiftpm/config/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Packages/ComposeUI/Package.resolved b/Packages/ComposeUI/Package.resolved
new file mode 100644
index 00000000..944ec2a7
--- /dev/null
+++ b/Packages/ComposeUI/Package.resolved
@@ -0,0 +1,23 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-url",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/karwa/swift-url.git",
+ "state" : {
+ "branch" : "main",
+ "revision" : "6f45f3cd6606f39c3753b302fe30aea980067b30"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Packages/ComposeUI/Package.swift b/Packages/ComposeUI/Package.swift
new file mode 100644
index 00000000..1de4d548
--- /dev/null
+++ b/Packages/ComposeUI/Package.swift
@@ -0,0 +1,33 @@
+// swift-tools-version: 5.7
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "ComposeUI",
+ platforms: [
+ .iOS(.v15),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, and make them visible to other packages.
+ .library(
+ name: "ComposeUI",
+ targets: ["ComposeUI"]),
+ ],
+ dependencies: [
+ // Dependencies declare other packages that this package depends on.
+ .package(path: "../Pachyderm"),
+ .package(path: "../InstanceFeatures"),
+ .package(path: "../TuskerComponents"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package. A target can define a module or a test suite.
+ // Targets can depend on other targets in this package, and on products in packages this package depends on.
+ .target(
+ name: "ComposeUI",
+ dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents"]),
+ .testTarget(
+ name: "ComposeUITests",
+ dependencies: ["ComposeUI"]),
+ ]
+)
diff --git a/Packages/ComposeUI/README.md b/Packages/ComposeUI/README.md
new file mode 100644
index 00000000..99e51ad0
--- /dev/null
+++ b/Packages/ComposeUI/README.md
@@ -0,0 +1,3 @@
+# ComposeUI
+
+A description of this package.
diff --git a/Tusker/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift
similarity index 90%
rename from Tusker/API/PostService.swift
rename to Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift
index 91a689aa..7d823359 100644
--- a/Tusker/API/PostService.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift
@@ -11,14 +11,16 @@ import Pachyderm
import UniformTypeIdentifiers
class PostService: ObservableObject {
- private let mastodonController: MastodonController
+ private let mastodonController: ComposeMastodonContext
+ private let config: ComposeUIConfig
private let draft: Draft
let totalSteps: Int
@Published var currentStep = 1
- init(mastodonController: MastodonController, draft: Draft) {
+ init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
self.mastodonController = mastodonController
+ self.config = config
self.draft = draft
// 2 steps (request data, then upload) for each attachment
self.totalSteps = 2 + (draft.attachments.count * 2)
@@ -40,7 +42,7 @@ class PostService: ObservableObject {
let request = Client.createStatus(
text: textForPosting(),
- contentType: Preferences.shared.statusContentType,
+ contentType: config.contentType,
inReplyTo: draft.inReplyToID,
media: uploadedAttachments,
sensitive: sensitive,
@@ -57,6 +59,7 @@ class PostService: ObservableObject {
currentStep += 1
DraftsManager.shared.remove(self.draft)
+ DraftsManager.save()
} catch let error as Client.Error {
throw Error.posting(error)
}
@@ -71,7 +74,7 @@ class PostService: ObservableObject {
do {
(data, utType) = try await getData(for: attachment)
currentStep += 1
- } catch let error as CompositionAttachmentData.Error {
+ } catch let error as AttachmentData.Error {
throw Error.attachmentData(index: index, cause: error)
}
do {
@@ -85,7 +88,7 @@ class PostService: ObservableObject {
return attachments
}
- private func getData(for attachment: CompositionAttachment) async throws -> (Data, UTType) {
+ private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
return try await withCheckedThrowingContinuation { continuation in
attachment.data.getData(features: mastodonController.instanceFeatures) { result in
switch result {
@@ -118,7 +121,7 @@ class PostService: ObservableObject {
}
enum Error: Swift.Error, LocalizedError {
- case attachmentData(index: Int, cause: CompositionAttachmentData.Error)
+ case attachmentData(index: Int, cause: AttachmentData.Error)
case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error)
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift
similarity index 67%
rename from Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift
rename to Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift
index 59bf1f25..2e2002cc 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift
@@ -1,24 +1,25 @@
//
// CharacterCounter.swift
-// Pachyderm
+// ComposeUI
//
// Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
+import InstanceFeatures
public struct CharacterCounter {
- static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
- static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
+ private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
+ private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
- public static func count(text: String, for instance: Instance? = nil) -> Int {
+ public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
let mentionsRemoved = removeMentions(in: text)
var count = mentionsRemoved.count
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
count -= match.range.length
- count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
+ count += instanceFeatures.charsReservedPerURL
}
return count
}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift
new file mode 100644
index 00000000..a5f47aaf
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift
@@ -0,0 +1,27 @@
+//
+// ComposeInput.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/5/23.
+//
+
+import Foundation
+import Combine
+
+protocol ComposeInput: AnyObject, ObservableObject {
+ var toolbarElements: [ToolbarElement] { get }
+
+ var autocompleteState: AutocompleteState? { get }
+ var autocompleteStatePublisher: Published.Publisher { get }
+
+ func autocomplete(with string: String)
+
+ func applyFormat(_ format: StatusFormat)
+
+ func beginAutocompletingEmoji()
+}
+
+enum ToolbarElement {
+ case emojiPicker
+ case formattingButtons
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift
new file mode 100644
index 00000000..2eb2482a
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift
@@ -0,0 +1,26 @@
+//
+// ComposeMastodonContext.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/5/23.
+//
+
+import Foundation
+import Pachyderm
+import InstanceFeatures
+import UserAccounts
+
+public protocol ComposeMastodonContext {
+ var accountInfo: UserAccountInfo? { get }
+ var instanceFeatures: InstanceFeatures { get }
+
+ func run(_ request: Request) async throws -> (Result, Pagination?)
+ func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
+
+ @MainActor
+ func searchCachedAccounts(query: String) -> [AccountProtocol]
+ @MainActor
+ func cachedRelationship(for accountID: String) -> RelationshipProtocol?
+ @MainActor
+ func searchCachedHashtags(query: String) -> [Hashtag]
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift
new file mode 100644
index 00000000..0cc3694a
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift
@@ -0,0 +1,35 @@
+//
+// ComposeUIConfig.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/4/23.
+//
+
+import SwiftUI
+import Pachyderm
+import PhotosUI
+import PencilKit
+import TuskerComponents
+
+public struct ComposeUIConfig {
+ 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
+ public var useTwitterKeyboard = false
+ public var contentType = StatusContentType.plain
+ public var automaticallySaveDrafts = false
+ public var requireAttachmentDescriptions = false
+
+ public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
+ public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
+ public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
+ public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil }
+
+ public init() {
+ }
+}
+
+extension ComposeUIConfig {
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift
new file mode 100644
index 00000000..4f09c9d9
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift
@@ -0,0 +1,162 @@
+//
+// AttachmentRowController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/12/23.
+//
+
+import SwiftUI
+import TuskerComponents
+import Vision
+
+class AttachmentRowController: ViewController {
+ let parent: ComposeController
+ let attachment: DraftAttachment
+
+ @Published var descriptionMode: DescriptionMode = .allowEntry
+ @Published var textRecognitionError: Error?
+
+ init(parent: ComposeController, attachment: DraftAttachment) {
+ self.parent = parent
+ self.attachment = attachment
+ }
+
+ var view: some View {
+ AttachmentView(attachment: attachment)
+ }
+
+ private func removeAttachment() {
+ withAnimation {
+ parent.draft.attachments.removeAll(where: { $0.id == attachment.id })
+ }
+ }
+
+ private func editDrawing() {
+ guard case .drawing(let drawing) = attachment.data else {
+ return
+ }
+ parent.config.presentDrawing?(drawing) { newDrawing in
+ self.attachment.data = .drawing(newDrawing)
+ }
+ }
+
+ private func recognizeText() {
+ descriptionMode = .recognizingText
+
+ DispatchQueue.global(qos: .userInitiated).async {
+ self.attachment.data.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
+ let data: Data
+ switch result {
+ case .success((let d, _)):
+ data = d
+ case .failure(let error):
+ self.descriptionMode = .allowEntry
+ self.textRecognitionError = error
+ return
+ }
+
+ let handler = VNImageRequestHandler(data: data)
+ let request = VNRecognizeTextRequest { request, error in
+ DispatchQueue.main.async {
+ if let results = request.results as? [VNRecognizedTextObservation] {
+ var text = ""
+ for observation in results {
+ let result = observation.topCandidates(1).first!
+ text.append(result.string)
+ text.append("\n")
+ }
+ self.attachment.attachmentDescription = text
+ }
+ self.descriptionMode = .allowEntry
+ }
+ }
+ request.recognitionLevel = .accurate
+ request.usesLanguageCorrection = true
+ DispatchQueue.global(qos: .userInitiated).async {
+ do {
+ try handler.perform([request])
+ } catch let error as NSError where error.code == 1 {
+ // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
+ return
+ } catch {
+ DispatchQueue.main.async {
+ self.descriptionMode = .allowEntry
+ self.textRecognitionError = error
+ }
+ }
+ }
+ }
+ }
+ }
+
+ struct AttachmentView: View {
+ @ObservedObject private var attachment: DraftAttachment
+ @EnvironmentObject private var controller: AttachmentRowController
+
+ init(attachment: DraftAttachment) {
+ self.attachment = attachment
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ AttachmentThumbnailView(attachment: attachment, fullSize: false)
+ .frame(width: 80, height: 80)
+ .cornerRadius(8)
+ .contextMenu {
+ if case .drawing(_) = attachment.data {
+ Button(action: controller.editDrawing) {
+ Label("Edit Drawing", systemImage: "hand.draw")
+ }
+ } else if attachment.data.type == .image {
+ Button(action: controller.recognizeText) {
+ Label("Recognize Text", systemImage: "doc.text.viewfinder")
+ }
+ }
+
+ Button(role: .destructive, action: controller.removeAttachment) {
+ Label("Delete", systemImage: "trash")
+ }
+ } previewIfAvailable: {
+ AttachmentThumbnailView(attachment: attachment, fullSize: true)
+ }
+
+ switch controller.descriptionMode {
+ case .allowEntry:
+ AttachmentDescriptionTextView(
+ text: $attachment.attachmentDescription,
+ placeholder: Text("Describe for the visually impaired…"),
+ minHeight: 80
+ )
+
+ case .recognizingText:
+ ProgressView()
+ .progressViewStyle(.circular)
+ }
+ }
+ .alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in
+ Button("OK") {}
+ } message: { error in
+ Text(error.localizedDescription)
+ }
+ }
+ }
+
+}
+
+extension AttachmentRowController {
+ enum DescriptionMode {
+ case allowEntry, recognizingText
+ }
+}
+
+private extension View {
+ @available(iOS, obsoleted: 16.0)
+ @ViewBuilder
+ func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
+ if #available(iOS 16.0, *) {
+ self.contextMenu(menuItems: menuItems, preview: preview)
+ } else {
+ self.contextMenu(menuItems: menuItems)
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift
new file mode 100644
index 00000000..182df4bf
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift
@@ -0,0 +1,233 @@
+//
+// AttachmentsListController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/8/23.
+//
+
+import SwiftUI
+import PhotosUI
+import PencilKit
+
+class AttachmentsListController: ViewController {
+
+ unowned let parent: ComposeController
+ var draft: Draft { parent.draft }
+
+ var isValid: Bool {
+ !requiresAttachmentDescriptions && validAttachmentCombination
+ }
+
+ private var requiresAttachmentDescriptions: Bool {
+ if parent.config.requireAttachmentDescriptions {
+ return draft.attachments.allSatisfy {
+ !$0.attachmentDescription.isEmpty
+ }
+ } else {
+ return false
+ }
+ }
+
+ private var validAttachmentCombination: Bool {
+ if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
+ return true
+ } else if draft.attachments.contains(where: { $0.data.type == .video }) &&
+ draft.attachments.count > 1 {
+ return false
+ } else if draft.attachments.count > 4 {
+ return false
+ }
+ return true
+ }
+
+ init(parent: ComposeController) {
+ self.parent = parent
+ }
+
+ private var canAddAttachment: Bool {
+ if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
+ return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil
+ } else {
+ return true
+ }
+ }
+
+ private var canAddPoll: Bool {
+ if parent.mastodonController.instanceFeatures.pollsAndAttachments {
+ return true
+ } else {
+ return draft.attachments.isEmpty
+ }
+ }
+
+ var view: some View {
+ AttachmentsList()
+ }
+
+ private func moveAttachments(from source: IndexSet, to destination: Int) {
+ draft.attachments.move(fromOffsets: source, toOffset: destination)
+ }
+
+ private func deleteAttachments(at indices: IndexSet) {
+ draft.attachments.remove(atOffsets: indices)
+ }
+
+ @MainActor
+ private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) async {
+ 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)
+ }
+ }
+ }
+ }
+
+ private func addImage() {
+ parent.config.presentAssetPicker?({ results in
+ Task {
+ await 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)))
+ }
+ }
+
+ private func togglePoll() {
+ UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
+
+ withAnimation {
+ draft.poll = draft.poll == nil ? Draft.Poll() : nil
+ }
+ }
+
+ struct AttachmentsList: View {
+ private let cellHeight: CGFloat = 80
+ private let cellPadding: CGFloat = 12
+
+ @EnvironmentObject private var controller: AttachmentsListController
+ @EnvironmentObject private var draft: Draft
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
+
+ var body: some View {
+ Group {
+ attachmentsList
+
+ if controller.parent.config.presentAssetPicker != nil {
+ addImageButton
+ .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
+ }
+
+ if controller.parent.config.presentDrawing != nil {
+ addDrawingButton
+ .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
+ }
+
+ togglePollButton
+ .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
+ }
+ }
+
+ private var attachmentsList: some View {
+ ForEach(draft.attachments) { 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)
+ }
+ })
+ }
+ }
+
+ private var addImageButton: some View {
+ Button(action: controller.addImage) {
+ Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
+ }
+ .disabled(!controller.canAddAttachment)
+ .foregroundColor(.accentColor)
+ .frame(height: cellHeight / 2)
+ }
+
+ private var addDrawingButton: some View {
+ Button(action: controller.addDrawing) {
+ Label("Draw something", systemImage: "hand.draw")
+ }
+ .disabled(!controller.canAddAttachment)
+ .foregroundColor(.accentColor)
+ .frame(height: cellHeight / 2)
+ }
+
+ private var togglePollButton: some View {
+ Button(action: controller.togglePoll) {
+ Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
+ }
+ .disabled(!controller.canAddPoll)
+ .foregroundColor(.accentColor)
+ .frame(height: cellHeight / 2)
+ }
+ }
+}
+
+fileprivate extension View {
+ @ViewBuilder
+ func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View {
+ if condition {
+ body(self)
+ } else {
+ self
+ }
+ }
+
+ @available(iOS, obsoleted: 16.0)
+ @ViewBuilder
+ func sheetOrPopover(isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View {
+ if #available(iOS 16.0, *) {
+ self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
+ } else {
+ self.popover(isPresented: isPresented, content: content)
+ }
+ }
+
+ @available(iOS, obsoleted: 16.0)
+ @ViewBuilder
+ func withSheetDetentsIfAvailable() -> some View {
+ if #available(iOS 16.0, *) {
+ self
+ .presentationDetents([.medium, .large])
+ .presentationDragIndicator(.visible)
+ } else {
+ self
+ }
+ }
+}
+
+@available(iOS 16.0, *)
+fileprivate struct SheetOrPopover: ViewModifier {
+ @Binding var isPresented: Bool
+ @ViewBuilder let view: () -> V
+
+ @Environment(\.horizontalSizeClass) var sizeClass
+
+ func body(content: Content) -> some View {
+ if sizeClass == .compact {
+ content.sheet(isPresented: $isPresented, content: view)
+ } else {
+ content.popover(isPresented: $isPresented, content: view)
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift
new file mode 100644
index 00000000..c6867d74
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift
@@ -0,0 +1,83 @@
+//
+// AutocompleteController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/25/23.
+//
+
+import SwiftUI
+import Combine
+
+class AutocompleteController: ViewController {
+
+ unowned let parent: ComposeController
+
+ @Published var mode: Mode?
+
+ init(parent: ComposeController) {
+ self.parent = parent
+
+ parent.$currentInput
+ .compactMap { $0 }
+ .flatMap { $0.autocompleteStatePublisher }
+ .map {
+ switch $0 {
+ case .mention(_):
+ return Mode.mention
+ case .emoji(_):
+ return Mode.emoji
+ case .hashtag(_):
+ return Mode.hashtag
+ case nil:
+ return nil
+ }
+ }
+ .assign(to: &$mode)
+ }
+
+ var view: some View {
+ AutocompleteView()
+ }
+
+ struct AutocompleteView: View {
+ @EnvironmentObject private var parent: ComposeController
+ @EnvironmentObject private var controller: AutocompleteController
+ @Environment(\.colorScheme) private var colorScheme: ColorScheme
+
+ var body: some View {
+ if let mode = controller.mode {
+ VStack(spacing: 0) {
+ Divider()
+ suggestionsView(mode: mode)
+ }
+ .background(backgroundColor)
+ }
+ }
+
+ @ViewBuilder
+ private func suggestionsView(mode: Mode) -> some View {
+ switch mode {
+ case .mention:
+ ControllerView(controller: { AutocompleteMentionsController(composeController: parent) })
+ case .emoji:
+ ControllerView(controller: { AutocompleteEmojisController(composeController: parent) })
+ case .hashtag:
+ ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) })
+ }
+ }
+
+ private var backgroundColor: Color {
+ Color(white: colorScheme == .light ? 0.98 : 0.15)
+ }
+
+ private var borderColor: Color {
+ Color(white: colorScheme == .light ? 0.85 : 0.25)
+ }
+ }
+
+ enum Mode {
+ case mention
+ case emoji
+ case hashtag
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift
new file mode 100644
index 00000000..fba41c9b
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift
@@ -0,0 +1,196 @@
+//
+// AutocompleteEmojisController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/26/23.
+//
+
+import SwiftUI
+import Pachyderm
+import Combine
+
+class AutocompleteEmojisController: ViewController {
+ unowned let composeController: ComposeController
+ var mastodonController: ComposeMastodonContext { composeController.mastodonController }
+
+ private var stateCancellable: AnyCancellable?
+ private var searchTask: Task?
+
+ @Published var expanded = false
+ @Published var emojis: [Emoji] = []
+
+ var emojisBySection: [String: [Emoji]] {
+ var values: [String: [Emoji]] = [:]
+ for emoji in emojis {
+ let key = emoji.category ?? ""
+ if !values.keys.contains(key) {
+ values[key] = [emoji]
+ } else {
+ values[key]!.append(emoji)
+ }
+ }
+ return values
+ }
+
+ init(composeController: ComposeController) {
+ self.composeController = composeController
+
+ stateCancellable = composeController.$currentInput
+ .compactMap { $0 }
+ .flatMap { $0.autocompleteStatePublisher }
+ .compactMap {
+ if case .emoji(let s) = $0 {
+ return s
+ } else {
+ return nil
+ }
+ }
+ .removeDuplicates()
+ .sink { [unowned self] query in
+ self.searchTask?.cancel()
+ self.searchTask = Task {
+ await self.queryChanged(query)
+ }
+ }
+ }
+
+ @MainActor
+ private func queryChanged(_ query: String) async {
+ var emojis = await withCheckedContinuation { continuation in
+ composeController.mastodonController.getCustomEmojis {
+ continuation.resume(returning: $0)
+ }
+ }
+ guard !Task.isCancelled else {
+ return
+ }
+
+ if !query.isEmpty {
+ emojis =
+ emojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
+ (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
+ }
+ .filter(\.1.matched)
+ .sorted { $0.1.score > $1.1.score }
+ .map(\.0)
+ }
+
+ var shortcodes = Set()
+ var newEmojis = [Emoji]()
+ for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
+ newEmojis.append(emoji)
+ shortcodes.insert(emoji.shortcode)
+ }
+ self.emojis = newEmojis
+ }
+
+ private func toggleExpanded() {
+ withAnimation {
+ expanded.toggle()
+ }
+ }
+
+ private func autocomplete(with emoji: Emoji) {
+ guard let input = composeController.currentInput else { return }
+ input.autocomplete(with: ":\(emoji.shortcode):")
+ }
+
+ var view: some View {
+ AutocompleteEmojisView()
+ }
+
+ struct AutocompleteEmojisView: View {
+ @EnvironmentObject private var composeController: ComposeController
+ @EnvironmentObject private var controller: AutocompleteEmojisController
+ @ScaledMetric private var emojiSize = 30
+
+ var body: some View {
+ // When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
+ HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
+ emojiList
+ .transition(.move(edge: .bottom))
+
+ toggleExpandedButton
+ .padding(.trailing, 8)
+ .padding(.top, controller.expanded ? 8 : 0)
+ }
+ }
+
+ @ViewBuilder
+ private var emojiList: some View {
+ if controller.expanded {
+ verticalGrid
+ .frame(height: 150)
+ } else {
+ horizontalScrollView
+ }
+ }
+
+ private var verticalGrid: some View {
+ ScrollView {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
+ ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
+ Section {
+ ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
+ Button(action: { controller.autocomplete(with: emoji) }) {
+ composeController.emojiImageView(emoji)
+ .frame(height: emojiSize)
+ }
+ .accessibilityLabel(emoji.shortcode)
+ }
+ } header: {
+ if !section.isEmpty {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(section)
+ .font(.caption)
+
+ Divider()
+ }
+ .padding(.top, 4)
+ }
+ }
+ }
+ }
+ .padding(.all, 8)
+ // the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
+ .padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private var horizontalScrollView: some View {
+ ScrollView(.horizontal) {
+ HStack(spacing: 8) {
+ ForEach(controller.emojis, id: \.shortcode) { emoji in
+ Button(action: { controller.autocomplete(with: emoji) }) {
+ HStack(spacing: 4) {
+ composeController.emojiImageView(emoji)
+ .frame(height: emojiSize)
+ Text(verbatim: ":\(emoji.shortcode):")
+ .foregroundColor(.primary)
+ }
+ }
+ .accessibilityLabel(emoji.shortcode)
+ .frame(height: emojiSize)
+ }
+ .animation(.linear(duration: 0.2), value: controller.emojis)
+
+ Spacer(minLength: emojiSize)
+ }
+ .padding(.horizontal, 8)
+ .frame(height: emojiSize + 16)
+ }
+ }
+
+ private var toggleExpandedButton: some View {
+ Button(action: controller.toggleExpanded) {
+ Image(systemName: "chevron.down")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .rotationEffect(controller.expanded ? .zero : .degrees(180))
+ }
+ .accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
+ .frame(width: 20, height: 20)
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift
new file mode 100644
index 00000000..3d964d1e
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift
@@ -0,0 +1,124 @@
+//
+// AutocompleteHashtagsController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 4/1/23.
+//
+
+import SwiftUI
+import Combine
+import Pachyderm
+
+class AutocompleteHashtagsController: ViewController {
+ unowned let composeController: ComposeController
+ var mastodonController: ComposeMastodonContext { composeController.mastodonController }
+
+ private var stateCancellable: AnyCancellable?
+ private var searchTask: Task?
+
+ @Published var hashtags: [Hashtag] = []
+
+ init(composeController: ComposeController) {
+ self.composeController = composeController
+
+ stateCancellable = composeController.$currentInput
+ .compactMap { $0 }
+ .flatMap { $0.autocompleteStatePublisher }
+ .compactMap {
+ if case .hashtag(let s) = $0 {
+ return s
+ } else {
+ return nil
+ }
+ }
+ .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
+ .sink { [unowned self] query in
+ self.searchTask?.cancel()
+ self.searchTask = Task {
+ await self.queryChanged(query)
+ }
+ }
+ }
+
+ @MainActor
+ private func queryChanged(_ query: String) async {
+ guard !query.isEmpty else {
+ hashtags = []
+ return
+ }
+
+ let localHashtags = mastodonController.searchCachedHashtags(query: query)
+
+ var onlyLocalTagsTask: Task?
+ if !localHashtags.isEmpty {
+ onlyLocalTagsTask = Task {
+ // we only want to do the local-only search if the trends API call takes more than .25sec or it fails
+ try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
+ self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query)
+ }
+ }
+
+ async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
+ async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
+
+ let trends = await trendingTags ?? []
+ let search = await searchResults ?? []
+
+ onlyLocalTagsTask?.cancel()
+ guard !Task.isCancelled else { return }
+
+ updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query)
+ }
+
+ @MainActor
+ private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) {
+ var addedHashtags = Set()
+ var hashtags = [(Hashtag, Int)]()
+ for group in [searchResults, trendingTags, localHashtags] {
+ for tag in group where !addedHashtags.contains(tag.name) {
+ let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
+ if matched {
+ hashtags.append((tag, score))
+ addedHashtags.insert(tag.name)
+ }
+ }
+ }
+ self.hashtags = hashtags
+ .sorted { $0.1 > $1.1 }
+ .map(\.0)
+ }
+
+ private func autocomplete(with hashtag: Hashtag) {
+ guard let currentInput = composeController.currentInput else { return }
+ currentInput.autocomplete(with: "#\(hashtag.name)")
+ }
+
+ var view: some View {
+ AutocompleteHashtagsView()
+ }
+
+ struct AutocompleteHashtagsView: View {
+ @EnvironmentObject private var controller: AutocompleteHashtagsController
+
+ var body: some View {
+ ScrollView(.horizontal) {
+ HStack(spacing: 8) {
+ ForEach(controller.hashtags, id: \.name) { hashtag in
+ Button(action: { controller.autocomplete(with: hashtag) }) {
+ Text(verbatim: "#\(hashtag.name)")
+ .foregroundColor(Color(uiColor: .label))
+ }
+ .frame(height: 30)
+ .padding(.vertical, 8)
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, 8)
+ .animation(.linear(duration: 0.2), value: controller.hashtags)
+ }
+ }
+ }
+
+
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift
new file mode 100644
index 00000000..0acfc1cd
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift
@@ -0,0 +1,178 @@
+//
+// AutocompleteMentionsController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/25/23.
+//
+
+import SwiftUI
+import Combine
+import Pachyderm
+import TuskerComponents
+
+class AutocompleteMentionsController: ViewController {
+
+ unowned let composeController: ComposeController
+ var mastodonController: ComposeMastodonContext { composeController.mastodonController }
+
+ private var stateCancellable: AnyCancellable?
+
+ @Published private var accounts: [AnyAccount] = []
+ private var searchTask: Task?
+
+ init(composeController: ComposeController) {
+ self.composeController = composeController
+
+ stateCancellable = composeController.$currentInput
+ .compactMap { $0 }
+ .flatMap { $0.autocompleteStatePublisher }
+ .compactMap {
+ if case .mention(let s) = $0 {
+ return s
+ } else {
+ return nil
+ }
+ }
+ .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
+ .sink { [unowned self] query in
+ self.searchTask?.cancel()
+ self.searchTask = Task {
+ await self.queryChanged(query)
+ }
+ }
+ }
+
+ @MainActor
+ private func queryChanged(_ query: String) async {
+ guard !query.isEmpty else {
+ accounts = []
+ return
+ }
+
+ let localSearchTask = Task {
+ // we only want to search locally if the search API call takes more than .25sec or it fails
+ try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
+
+ let results = self.mastodonController.searchCachedAccounts(query: query)
+ try Task.checkCancellation()
+
+ if !results.isEmpty {
+ self.loadAccounts(results.map { .init(value: $0) }, query: query)
+ }
+ }
+
+ let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0
+ guard let accounts,
+ !Task.isCancelled else {
+ return
+ }
+ localSearchTask.cancel()
+
+ loadAccounts(accounts.map { .init(value: $0) }, query: query)
+ }
+
+ @MainActor
+ private func loadAccounts(_ accounts: [AnyAccount], query: String) {
+ guard case .mention(query) = composeController.currentInput?.autocompleteState else {
+ return
+ }
+
+ // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
+ let ignoreDomain = !query.contains("@")
+
+ self.accounts =
+ accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
+ let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
+ let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
+ return res
+ }
+ .filter(\.1.matched)
+ .map { (account, res) -> (AnyAccount, Int) in
+ // give higher weight to accounts that the user follows or is followed by
+ var score = res.score
+ if let relationship = mastodonController.cachedRelationship(for: account.value.id) {
+ if relationship.following {
+ score += 3
+ }
+ if relationship.followedBy {
+ score += 2
+ }
+ }
+ return (account, score)
+ }
+ .sorted { $0.1 > $1.1 }
+ .map(\.0)
+ }
+
+ private func autocomplete(with account: AnyAccount) {
+ guard let input = composeController.currentInput else {
+ return
+ }
+ input.autocomplete(with: "@\(account.value.acct)")
+ }
+
+ var view: some View {
+ AutocompleteMentionsView()
+ }
+
+ struct AutocompleteMentionsView: View {
+ @EnvironmentObject private var controller: AutocompleteMentionsController
+
+ var body: some View {
+ ScrollView(.horizontal) {
+ HStack(spacing: 8) {
+ ForEach(controller.accounts) { account in
+ AutocompleteMentionButton(account: account)
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, 8)
+ .animation(.linear(duration: 0.2), value: controller.accounts)
+ }
+ .onDisappear {
+ controller.searchTask?.cancel()
+ }
+ }
+ }
+
+ private struct AutocompleteMentionButton: View {
+ @EnvironmentObject private var composeController: ComposeController
+ @EnvironmentObject private var controller: AutocompleteMentionsController
+ let account: AnyAccount
+
+ var body: some View {
+ Button(action: { controller.autocomplete(with: account) }) {
+ HStack(spacing: 4) {
+ AvatarImageView(
+ url: account.value.avatar,
+ size: 30,
+ style: composeController.config.avatarStyle,
+ fetchAvatar: composeController.fetchAvatar
+ )
+
+ VStack(alignment: .leading) {
+ controller.composeController.displayNameLabel(account.value, .subheadline, 14)
+ .foregroundColor(.primary)
+
+ Text(verbatim: "@\(account.value.acct)")
+ .font(.caption)
+ .foregroundColor(.primary)
+ }
+ }
+ }
+ .frame(height: 30)
+ .padding(.vertical, 8)
+ }
+ }
+}
+
+fileprivate struct AnyAccount: Equatable, Identifiable {
+ let value: any AccountProtocol
+
+ var id: String { value.id }
+
+ static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
+ return lhs.value.id == rhs.value.id
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift
new file mode 100644
index 00000000..ceb8a227
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift
@@ -0,0 +1,379 @@
+//
+// ComposeController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/4/23.
+//
+
+import SwiftUI
+import Combine
+import Pachyderm
+import TuskerComponents
+
+public final class ComposeController: ViewController {
+ public typealias FetchStatus = (String) -> (any StatusProtocol)?
+ public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> 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
+ let fetchAvatar: AvatarImageView.FetchAvatar
+ let fetchStatus: FetchStatus
+ let displayNameLabel: DisplayNameLabel
+ let replyContentView: ReplyContentView
+ let emojiImageView: EmojiImageView
+
+ @Published public var currentAccount: (any AccountProtocol)?
+ @Published public var showToolbar = true
+
+ @Published var autocompleteController: AutocompleteController!
+ @Published var toolbarController: ToolbarController!
+ @Published var attachmentsListController: AttachmentsListController!
+
+ @Published var contentWarningBecomeFirstResponder = false
+ @Published var mainComposeTextViewBecomeFirstResponder = false
+ @Published var currentInput: (any ComposeInput)? = nil
+ @Published var shouldEmojiAutocompletionBeginExpanded = false
+ @Published var isShowingSaveDraftSheet = false
+ @Published var isShowingDraftsList = false
+ @Published var poster: PostService?
+ @Published var postError: (any Error)?
+
+ var isPosting: Bool {
+ poster != nil
+ }
+
+ var charactersRemaining: Int {
+ let instanceFeatures = mastodonController.instanceFeatures
+ let limit = instanceFeatures.maxStatusChars
+ let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
+ return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
+ }
+
+ var postButtonEnabled: Bool {
+ draft.hasContent
+ && charactersRemaining >= 0
+ && !isPosting
+ && attachmentsListController.isValid
+ && isPollValid
+ }
+
+ private var isPollValid: Bool {
+ draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }
+ }
+
+ public init(
+ draft: Draft,
+ config: ComposeUIConfig,
+ mastodonController: ComposeMastodonContext,
+ fetchAvatar: @escaping AvatarImageView.FetchAvatar,
+ fetchStatus: @escaping FetchStatus,
+ displayNameLabel: @escaping DisplayNameLabel,
+ replyContentView: @escaping ReplyContentView,
+ emojiImageView: @escaping EmojiImageView
+ ) {
+ self.draft = draft
+ self.config = config
+ self.mastodonController = mastodonController
+ self.fetchAvatar = fetchAvatar
+ self.fetchStatus = fetchStatus
+ self.displayNameLabel = displayNameLabel
+ self.replyContentView = replyContentView
+ self.emojiImageView = emojiImageView
+
+ self.autocompleteController = AutocompleteController(parent: self)
+ self.toolbarController = ToolbarController(parent: self)
+ self.attachmentsListController = AttachmentsListController(parent: self)
+ }
+
+ public var view: some View {
+ ComposeView(poster: poster)
+ .environmentObject(draft)
+ .environmentObject(mastodonController.instanceFeatures)
+ }
+
+ public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
+ guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
+ return false
+ }
+ if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
+ if draft.attachments.allSatisfy({ $0.data.type == .image }) {
+ // if providers are videos, this technically allows invalid video/image combinations
+ return itemProviders.count + draft.attachments.count <= 4
+ } else {
+ return false
+ }
+ } else {
+ return true
+ }
+ }
+
+ public func paste(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 {
+ self.draft.attachments.append(attachment)
+ }
+ }
+ }
+ }
+
+ @MainActor
+ func cancel() {
+ if config.automaticallySaveDrafts {
+ config.dismiss(.cancel)
+ } else {
+ if draft.hasContent {
+ isShowingSaveDraftSheet = true
+ } else {
+ DraftsManager.shared.remove(draft)
+ config.dismiss(.cancel)
+ }
+ }
+ }
+
+ func postStatus() {
+ guard !isPosting,
+ draft.hasContent else {
+ return
+ }
+
+ Task { @MainActor in
+ let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
+ self.poster = poster
+
+ // try to resign the first responder, if there is one.
+ // otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
+ // and the first responder to change during a view update, which in turn triggers a bunch of state changes
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+
+ do {
+ try await poster.post()
+
+ // 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
+
+ } catch let error as PostService.Error {
+ self.postError = error
+ self.poster = nil
+ } catch {
+ fatalError("unreachable")
+ }
+ }
+ }
+
+ func showDrafts() {
+ isShowingDraftsList = true
+ }
+
+ func selectDraft(_ draft: Draft) {
+ if !self.draft.hasContent {
+ DraftsManager.shared.remove(self.draft)
+ }
+ DraftsManager.save()
+
+ self.draft = draft
+ }
+
+ func onDisappear() {
+ if !draft.hasContent {
+ DraftsManager.shared.remove(draft)
+ }
+ DraftsManager.save()
+ }
+
+ func toggleContentWarning() {
+ draft.contentWarningEnabled.toggle()
+ if draft.contentWarningEnabled {
+ contentWarningBecomeFirstResponder = true
+ }
+ }
+
+ struct ComposeView: View {
+ @OptionalObservedObject var poster: PostService?
+ @EnvironmentObject var controller: ComposeController
+ @EnvironmentObject var draft: Draft
+ @StateObject private var keyboardReader = KeyboardReader()
+ @State private var globalFrameOutsideList = CGRect.zero
+
+ init(poster: PostService?) {
+ self.poster = poster
+ }
+
+ var config: ComposeUIConfig {
+ controller.config
+ }
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
+ config.backgroundColor
+ .edgesIgnoringSafeArea(.all)
+
+ mainList
+
+ if let poster = poster {
+ // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
+ WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
+ }
+ }
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ if controller.showToolbar {
+ VStack(spacing: 0) {
+ ControllerView(controller: { controller.autocompleteController })
+ .transition(.move(edge: .bottom))
+ .animation(.default, value: controller.currentInput?.autocompleteState)
+
+ ControllerView(controller: { controller.toolbarController })
+ }
+ // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
+ .padding(.bottom, keyboardInset)
+ .transition(.move(edge: .bottom))
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) { cancelButton }
+ ToolbarItem(placement: .confirmationAction) { postButton }
+ }
+ .background(GeometryReader { proxy in
+ Color.clear
+ .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
+ .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
+ globalFrameOutsideList = newValue
+ }
+ })
+ .sheet(isPresented: $controller.isShowingDraftsList) {
+ ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
+ }
+ .alertWithData("Error Posting", data: $controller.postError, actions: { _ in
+ Button("OK") {}
+ }, message: { error in
+ Text(error.localizedDescription)
+ })
+ .onDisappear(perform: controller.onDisappear)
+ .navigationTitle(navTitle)
+ }
+
+ private var navTitle: String {
+ if let id = draft.inReplyToID,
+ let status = controller.fetchStatus(id) {
+ return "Reply to @\(status.account.acct)"
+ } else {
+ return "New Post"
+ }
+ }
+
+ private var mainList: some View {
+ List {
+ if let id = draft.inReplyToID,
+ let status = controller.fetchStatus(id) {
+ ReplyStatusView(
+ status: status,
+ rowTopInset: 8,
+ globalFrameOutsideList: globalFrameOutsideList
+ )
+ .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
+ .listRowSeparator(.hidden)
+ .listRowBackground(config.backgroundColor)
+ }
+
+ HeaderView()
+ .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
+ .listRowSeparator(.hidden)
+ .listRowBackground(config.backgroundColor)
+
+ if draft.contentWarningEnabled {
+ EmojiTextField(
+ text: $draft.contentWarning,
+ placeholder: "Write your warning here",
+ maxLength: nil,
+ becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
+ focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
+ )
+ .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
+ .listRowSeparator(.hidden)
+ .listRowBackground(config.backgroundColor)
+ }
+
+ MainTextView()
+ .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
+ .listRowSeparator(.hidden)
+ .listRowBackground(config.backgroundColor)
+
+ if let poll = draft.poll {
+ ControllerView(controller: { PollController(parent: controller, poll: poll) })
+ .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
+ .listRowSeparator(.hidden)
+ .listRowBackground(config.backgroundColor)
+ }
+
+ ControllerView(controller: { controller.attachmentsListController })
+ .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
+ .listRowBackground(config.backgroundColor)
+ }
+ .listStyle(.plain)
+ .scrollDismissesKeyboardInteractivelyIfAvailable()
+ .disabled(controller.isPosting)
+ }
+
+ private var cancelButton: some View {
+ Button(action: controller.cancel) {
+ Text("Cancel")
+ // otherwise all Buttons in the nav bar are made semibold
+ .font(.system(size: 17, weight: .regular))
+ }
+ }
+
+ @ViewBuilder
+ private var postButton: some View {
+ if draft.hasContent {
+ Button(action: controller.postStatus) {
+ Text("Post")
+ }
+ .keyboardShortcut(.return, modifiers: .command)
+ .disabled(!controller.postButtonEnabled)
+ } else {
+ Button(action: controller.showDrafts) {
+ Text("Drafts")
+ }
+ }
+ }
+
+ @available(iOS, obsoleted: 16.0)
+ private var keyboardInset: CGFloat {
+ if #unavailable(iOS 16.0),
+ UIDevice.current.userInterfaceIdiom == .pad,
+ keyboardReader.isVisible {
+ return ToolbarController.height
+ } else {
+ return 0
+ }
+ }
+ }
+}
+
+private extension View {
+ @available(iOS, obsoleted: 16.0)
+ @ViewBuilder
+ func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
+ if #available(iOS 16.0, *) {
+ self.scrollDismissesKeyboard(.interactively)
+ } else {
+ self
+ }
+ }
+}
+
+private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
+ static var defaultValue: CGRect = .zero
+ static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
+ value = nextValue()
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift
new file mode 100644
index 00000000..8ee4c017
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift
@@ -0,0 +1,165 @@
+//
+// DraftsController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/7/23.
+//
+
+import SwiftUI
+import TuskerComponents
+
+class DraftsController: ViewController {
+
+ unowned let parent: ComposeController
+ @Binding var isPresented: Bool
+
+ @Published var draftForDifferentReply: Draft?
+
+ init(parent: ComposeController, isPresented: Binding) {
+ self.parent = parent
+ self._isPresented = isPresented
+ }
+
+ var view: some View {
+ DraftsRepresentable()
+ }
+
+ func maybeSelectDraft(_ draft: Draft) {
+ if draft.inReplyToID != parent.draft.inReplyToID,
+ parent.draft.hasContent {
+ draftForDifferentReply = draft
+ } else {
+ confirmSelectDraft(draft)
+ }
+ }
+
+ func cancelSelectingDraft() {
+ draftForDifferentReply = nil
+ }
+
+ func confirmSelectDraft(_ draft: Draft) {
+ parent.selectDraft(draft)
+ closeDrafts()
+ }
+
+ func deleteDraft(_ draft: Draft) {
+ DraftsManager.shared.remove(draft)
+ }
+
+ func closeDrafts() {
+ isPresented = false
+ DraftsManager.save()
+ }
+
+ struct DraftsRepresentable: UIViewControllerRepresentable {
+ typealias UIViewControllerType = UIHostingController
+
+ func makeUIViewController(context: Context) -> UIHostingController {
+ return UIHostingController(rootView: DraftsView())
+ }
+
+ func updateUIViewController(_ uiViewController: UIHostingController, context: Context) {
+ }
+ }
+
+ 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
+ }
+ }
+
+ var body: some View {
+ NavigationView {
+ List {
+ ForEach(visibleDrafts) { draft in
+ Button(action: { controller.maybeSelectDraft(draft) }) {
+ DraftRow(draft: draft)
+ }
+ .contextMenu {
+ Button(role: .destructive, action: { controller.deleteDraft(draft) }) {
+ Label("Delete Draft", systemImage: "trash")
+ }
+ }
+ .ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in
+ view.onDrag { activity }
+ })
+ }
+ .onDelete { indices in
+ indices.map { visibleDrafts[$0] }.forEach(controller.deleteDraft)
+ }
+ }
+ .listStyle(.plain)
+ .navigationTitle("Drafts")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) { cancelButton }
+ }
+ }
+ .alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in
+ Button(role: .cancel, action: controller.cancelSelectingDraft) {
+ Text("Cancel")
+ }
+ Button(action: { controller.confirmSelectDraft(draft) }) {
+ Text("Restore Draft")
+ }
+ } message: { _ in
+ Text("The selected draft is a reply to a different post, do you wish to use it?")
+ }
+ }
+
+ private var cancelButton: some View {
+ Button(action: controller.closeDrafts) {
+ Text("Cancel")
+ }
+ }
+ }
+}
+
+private struct DraftRow: View {
+ @ObservedObject var draft: Draft
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading) {
+ if draft.contentWarningEnabled {
+ Text(draft.contentWarning)
+ .font(.body.bold())
+ .foregroundColor(.secondary)
+ }
+
+ Text(draft.text)
+ .font(.body)
+
+ HStack(spacing: 8) {
+ ForEach(draft.attachments) { attachment in
+ AttachmentThumbnailView(attachment: attachment, fullSize: false)
+ .frame(width: 50, height: 50)
+ .cornerRadius(5)
+ }
+ }
+ }
+
+ Spacer()
+
+ Text(draft.lastModified.formatted(.abbreviatedTimeAgo))
+ .font(.body)
+ .foregroundColor(.secondary)
+ }
+ }
+}
+
+private extension View {
+ @ViewBuilder
+ func ifLet(_ value: T?, modify: (Self, T) -> V) -> some View {
+ if let value {
+ modify(self, value)
+ } else {
+ self
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift
new file mode 100644
index 00000000..0b04a5d4
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift
@@ -0,0 +1,48 @@
+//
+// PlaceholderController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/6/23.
+//
+
+import SwiftUI
+
+final class PlaceholderController: ViewController, PlaceholderViewProvider {
+
+ private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
+
+ static func makePlaceholderView() -> some View {
+ let components = Calendar.current.dateComponents([.month, .day], from: Date())
+ if components.month == 3 && components.day == 14,
+ Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
+ Text("Happy π day!")
+ } else if components.month == 4 && components.day == 1 {
+ Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
+ } else if components.month == 9 && components.day == 5 {
+ // https://weirder.earth/@noracodes/109276419847254552
+ // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
+ Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
+ } else if components.month == 9 && components.day == 21 {
+ Text("Do you remember?")
+ } else if components.month == 10 && components.day == 31 {
+ if .random() {
+ Text("Post something spooky!")
+ } else {
+ Text("Any questions?")
+ }
+ } else {
+ Text("What's on your mind?")
+ }
+ }
+
+ var view: some View {
+ placeholderView
+ }
+}
+
+// exists to provide access to the type alias since the @State property needs it to be explicit
+private protocol PlaceholderViewProvider {
+ associatedtype PlaceholderView: View
+ @ViewBuilder
+ static func makePlaceholderView() -> PlaceholderView
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift
new file mode 100644
index 00000000..c13b8d85
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift
@@ -0,0 +1,182 @@
+//
+// PollController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/25/23.
+//
+
+import SwiftUI
+import TuskerComponents
+
+class PollController: ViewController {
+
+ unowned let parent: ComposeController
+ var draft: Draft { parent.draft }
+ let poll: Draft.Poll
+
+ @Published var duration: Duration
+
+ init(parent: ComposeController, poll: Draft.Poll) {
+ self.parent = parent
+ self.poll = poll
+ self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
+ }
+
+ var view: some View {
+ PollView()
+ .environmentObject(poll)
+ }
+
+ private func removePoll() {
+ withAnimation {
+ draft.poll = nil
+ }
+ }
+
+ private func moveOptions(indices: IndexSet, newIndex: Int) {
+ poll.options.move(fromOffsets: indices, toOffset: newIndex)
+ }
+
+ private func removeOption(_ option: Draft.Poll.Option) {
+ poll.options.removeAll(where: { $0.id == option.id })
+ }
+
+ private var canAddOption: Bool {
+ if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount {
+ return poll.options.count < max
+ } else {
+ return true
+ }
+ }
+
+ private func addOption() {
+ poll.options.append(.init(""))
+ }
+
+ struct PollView: View {
+ @EnvironmentObject private var controller: PollController
+ @EnvironmentObject private var poll: Draft.Poll
+ @Environment(\.colorScheme) private var colorScheme
+
+ var body: some View {
+ VStack {
+ HStack {
+ Text("Poll")
+ .font(.headline)
+
+ Spacer()
+
+ Button(action: controller.removePoll) {
+ Image(systemName: "xmark")
+ .imageScale(.small)
+ .padding(4)
+ }
+ .accessibilityLabel("Remove poll")
+ .buttonStyle(.plain)
+ .accentColor(buttonForegroundColor)
+ .background(Circle().foregroundColor(buttonBackgroundColor))
+ .hoverEffect()
+ }
+
+ List {
+ ForEach(poll.options) { option in
+ PollOptionView(option: option, remove: { controller.removeOption(option) })
+ .frame(height: 36)
+ .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
+ .listRowSeparator(.hidden)
+ .listRowBackground(Color.clear)
+ }
+ .onMove(perform: controller.moveOptions)
+ }
+ .listStyle(.plain)
+ .scrollDisabledIfAvailable(true)
+ .frame(height: 44 * CGFloat(poll.options.count))
+
+ Button(action: controller.addOption) {
+ Label {
+ Text("Add Option")
+ } icon: {
+ Image(systemName: "plus")
+ .foregroundColor(.accentColor)
+ }
+ }
+ .buttonStyle(.borderless)
+ .disabled(!controller.canAddOption)
+
+ HStack {
+ MenuPicker(selection: $poll.multiple, options: [
+ .init(value: true, title: "Allow multiple"),
+ .init(value: false, title: "Single choice"),
+ ])
+ .frame(maxWidth: .infinity)
+
+ MenuPicker(selection: $controller.duration, options: Duration.allCases.map {
+ .init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!)
+ })
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(8)
+ .background(
+ backgroundColor
+ .cornerRadius(10)
+ )
+ .onChange(of: controller.duration) { newValue in
+ poll.duration = newValue.timeInterval
+ }
+ }
+
+ private var backgroundColor: Color {
+ // in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
+ colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95)
+ }
+
+ private var buttonForegroundColor: Color {
+ Color(uiColor: .label)
+ }
+
+ private var buttonBackgroundColor: Color {
+ Color(white: colorScheme == .dark ? 0.1 : 0.8)
+ }
+ }
+}
+
+extension PollController {
+ enum Duration: Hashable, Equatable, CaseIterable {
+ case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
+
+ static let formatter: DateComponentsFormatter = {
+ let f = DateComponentsFormatter()
+ f.maximumUnitCount = 1
+ f.unitsStyle = .full
+ f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
+ return f
+ }()
+
+ static func fromTimeInterval(_ ti: TimeInterval) -> Duration? {
+ for it in allCases where it.timeInterval == ti {
+ return it
+ }
+ return nil
+ }
+
+ var timeInterval: TimeInterval {
+ switch self {
+ case .fiveMinutes:
+ return 5 * 60
+ case .thirtyMinutes:
+ return 30 * 60
+ case .oneHour:
+ return 60 * 60
+ case .sixHours:
+ return 6 * 60 * 60
+ case .oneDay:
+ return 24 * 60 * 60
+ case .threeDays:
+ return 3 * 24 * 60 * 60
+ case .sevenDays:
+ return 7 * 24 * 60 * 60
+ }
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift
new file mode 100644
index 00000000..a5002d3b
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift
@@ -0,0 +1,160 @@
+//
+// ToolbarController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/7/23.
+//
+
+import SwiftUI
+import Pachyderm
+import TuskerComponents
+
+class ToolbarController: ViewController {
+ static let height: CGFloat = 44
+ private static let visibilityOptions: [MenuPicker.Option] = Pachyderm.Visibility.allCases.map { vis in
+ .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
+ }
+
+ unowned let parent: ComposeController
+
+ @Published var minWidth: CGFloat?
+ @Published var realWidth: CGFloat?
+
+ init(parent: ComposeController) {
+ self.parent = parent
+ }
+
+ var view: some View {
+ ToolbarView()
+ }
+
+ func showEmojiPicker() {
+ guard parent.currentInput?.autocompleteState == nil else {
+ return
+ }
+ parent.shouldEmojiAutocompletionBeginExpanded = true
+ parent.currentInput?.beginAutocompletingEmoji()
+ }
+
+ func formatAction(_ format: StatusFormat) -> () -> Void {
+ { [weak self] in
+ self?.parent.currentInput?.applyFormat(format)
+ }
+ }
+
+ struct ToolbarView: View {
+ @EnvironmentObject private var draft: Draft
+ @EnvironmentObject private var controller: ToolbarController
+ @EnvironmentObject private var composeController: ComposeController
+ @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
+
+ @State private var minWidth: CGFloat?
+ @State private var realWidth: CGFloat?
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 0) {
+ cwButton
+
+ MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
+ // the button has a bunch of extra space by default, but combined with what we add it's too much
+ .padding(.horizontal, -8)
+
+ if composeController.mastodonController.instanceFeatures.localOnlyPosts {
+ localOnlyPicker
+ .padding(.horizontal, -8)
+ }
+
+ if let currentInput = composeController.currentInput,
+ currentInput.toolbarElements.contains(.emojiPicker) {
+ customEmojiButton
+ }
+
+ if let currentInput = composeController.currentInput,
+ currentInput.toolbarElements.contains(.formattingButtons),
+ composeController.config.contentType != .plain {
+
+ Spacer()
+ formatButtons
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .frame(minWidth: minWidth)
+ .background(GeometryReader { proxy in
+ Color.clear
+ .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
+ .onPreferenceChange(ToolbarWidthPrefKey.self) { width in
+ realWidth = width
+ }
+ })
+ }
+ .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
+ .frame(height: ToolbarController.height)
+ .frame(maxWidth: .infinity)
+ .background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
+ .overlay(alignment: .top) {
+ Divider()
+ }
+ .background(GeometryReader { proxy in
+ Color.clear
+ .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
+ .onPreferenceChange(ToolbarWidthPrefKey.self) { width in
+ minWidth = width
+ }
+ })
+ }
+
+ private var cwButton: some View {
+ Button("CW", action: controller.parent.toggleContentWarning)
+ .accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
+ .padding(5)
+ .hoverEffect()
+ }
+
+ private var localOnlyPicker: some View {
+ let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
+ return MenuPicker(selection: $draft.localOnly, options: [
+ .init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
+ .init(value: false, title: "Federated", image: UIImage(systemName: "link")),
+ ], buttonStyle: .iconOnly)
+ }
+
+ private var customEmojiButton: some View {
+ Button(action: controller.showEmojiPicker) {
+ Label("Insert custom emoji", systemImage: "face.smiling")
+ }
+ .labelStyle(.iconOnly)
+ .font(.system(size: imageSize))
+ .padding(5)
+ .hoverEffect()
+ .transition(.opacity.animation(.linear(duration: 0.2)))
+ }
+
+ private var formatButtons: some View {
+ ForEach(StatusFormat.allCases, id: \.rawValue) { format in
+ Button(action: controller.formatAction(format)) {
+ if let imageName = format.imageName {
+ Image(systemName: imageName)
+ .font(.system(size: imageSize))
+ } else if let (str, attrs) = format.title {
+ let container = try! AttributeContainer(attrs, including: \.uiKit)
+ Text(AttributedString(str, attributes: container))
+ }
+ }
+ .accessibilityLabel(format.accessibilityLabel)
+ .padding(5)
+ .hoverEffect()
+ .transition(.opacity.animation(.linear(duration: 0.2)))
+ }
+ }
+ }
+}
+
+private struct ToolbarWidthPrefKey: PreferenceKey {
+ static var defaultValue: CGFloat? = nil
+ static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
+ value = nextValue()
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift b/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift
new file mode 100644
index 00000000..a1344836
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift
@@ -0,0 +1,62 @@
+//
+// FuzzyMatcher.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 10/10/20.
+// Copyright © 2020 Shadowfacts. All rights reserved.
+//
+
+import Foundation
+
+struct FuzzyMatcher {
+
+ private init() {}
+
+ /// Rudimentary string fuzzy matching algorithm.
+ ///
+ /// Operates on UTF-8 code points, so attempting to match strings which include characters composed of
+ /// multiple code points may produce unexpected results.
+ ///
+ /// Scoring is as follows:
+ /// +2 points for every char in `pattern` that occurs in `str` sequentially
+ /// -2 points for every char in `pattern` that does not occur in `str` sequentially
+ /// -1 point for every char in `str` skipped between matching chars from the `pattern`
+ static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
+ let pattern = pattern.lowercased()
+ let str = str.lowercased()
+
+ var patternIndex = pattern.utf8.startIndex
+ var lastStrMatchIndex: String.UTF8View.Index?
+ var strIndex = str.utf8.startIndex
+
+ var score = 0
+
+ while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex {
+ let patternChar = pattern.utf8[patternIndex]
+ let strChar = str.utf8[strIndex]
+ if patternChar == strChar {
+ let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex)
+ if distance > 1 {
+ score -= distance - 1
+ }
+
+ patternIndex = pattern.utf8.index(after: patternIndex)
+ lastStrMatchIndex = strIndex
+ strIndex = str.utf8.index(after: strIndex)
+
+ score += 2
+ } else {
+ strIndex = str.utf8.index(after: strIndex)
+
+ if strIndex >= str.utf8.endIndex {
+ patternIndex = pattern.utf8.index(after: patternIndex)
+ strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex)
+ score -= 2
+ }
+ }
+ }
+
+ return (score > 0, score)
+ }
+
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift
new file mode 100644
index 00000000..323dfe83
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift
@@ -0,0 +1,29 @@
+//
+// KeyboardReader.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/7/23.
+//
+
+import UIKit
+import Combine
+
+@available(iOS, obsoleted: 16.0)
+class KeyboardReader: ObservableObject {
+ @Published var isVisible = false
+
+ init() {
+ NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
+ NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
+ }
+
+ @objc func willShow(_ notification: Foundation.Notification) {
+ // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
+ let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
+ isVisible = endFrame.height > 72
+ }
+
+ @objc func willHide() {
+ isVisible = false
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift
new file mode 100644
index 00000000..7bd31cd2
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift
@@ -0,0 +1,278 @@
+//
+// AttachmentData.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 1/1/20.
+// Copyright © 2020 Shadowfacts. All rights reserved.
+//
+
+import UIKit
+import Photos
+import UniformTypeIdentifiers
+import PencilKit
+import InstanceFeatures
+
+enum AttachmentData {
+ case asset(PHAsset)
+ case image(Data, originalType: UTType)
+ case video(URL)
+ case drawing(PKDrawing)
+ case gif(Data)
+
+ var type: AttachmentType {
+ switch self {
+ case let .asset(asset):
+ return asset.attachmentType!
+ case .image(_, originalType: _):
+ return .image
+ case .video(_):
+ return .video
+ case .drawing(_):
+ return .image
+ case .gif(_):
+ return .image
+ }
+ }
+
+ var isAsset: Bool {
+ switch self {
+ case .asset(_):
+ return true
+ default:
+ return false
+ }
+ }
+
+ var canSaveToDraft: Bool {
+ switch self {
+ case .video(_):
+ return false
+ default:
+ return true
+ }
+ }
+
+ func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
+ switch self {
+ case let .image(originalData, originalType):
+ let data: Data
+ let type: UTType
+ switch originalType {
+ case .png, .jpeg:
+ data = originalData
+ type = originalType
+ default:
+ let image = UIImage(data: originalData)!
+ // 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 processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion)
+ completion(.success(processed))
+ case let .asset(asset):
+ 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 = data, let dataUTI = dataUTI else {
+ completion(.failure(.missingData))
+ return
+ }
+ let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
+ completion(.success(processed))
+ }
+ } else if asset.mediaType == .video {
+ let options = PHVideoRequestOptions()
+ options.deliveryMode = .automatic
+ options.isNetworkAccessAllowed = true
+ options.version = .current
+ PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
+ if let exportSession = exportSession {
+ AttachmentData.exportVideoData(session: exportSession, completion: completion)
+ } else if let error = info?[PHImageErrorKey] as? Error {
+ completion(.failure(.videoExport(error)))
+ } else {
+ completion(.failure(.noVideoExportSession))
+ }
+ }
+ } else {
+ fatalError("assetType must be either image or video")
+ }
+ case let .video(url):
+ let asset = AVURLAsset(url: url)
+ guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
+ completion(.failure(.noVideoExportSession))
+ return
+ }
+ AttachmentData.exportVideoData(session: session, completion: completion)
+
+ case let .drawing(drawing):
+ let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
+ completion(.success((image.pngData()!, .png)))
+ case let .gif(data):
+ completion(.success((data, .gif)))
+ }
+ }
+
+ private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
+ guard !skipAllConversion else {
+ return (data, type)
+ }
+
+ var data = data
+ var type = type
+ 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), Error>) -> 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 AttachmentType {
+ case image, video
+ }
+
+ enum Error: Swift.Error, LocalizedError {
+ case missingData
+ case videoExport(Swift.Error)
+ case noVideoExportSession
+
+ var localizedDescription: String {
+ switch self {
+ case .missingData:
+ return "Missing Data"
+ case .videoExport(let error):
+ return "Exporting video: \(error)"
+ case .noVideoExportSession:
+ return "Couldn't create video export session"
+ }
+ }
+ }
+}
+
+extension PHAsset {
+ var attachmentType: AttachmentData.AttachmentType? {
+ switch self.mediaType {
+ case .image:
+ return .image
+ case .video:
+ return .video
+ default:
+ return nil
+ }
+ }
+}
+
+extension AttachmentData: Codable {
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ switch self {
+ case let .asset(asset):
+ try container.encode("asset", forKey: .type)
+ try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
+ case let .image(originalData, originalType):
+ try container.encode("image", forKey: .type)
+ try container.encode(originalType, forKey: .imageType)
+ try container.encode(originalData, forKey: .imageData)
+ case .video(_):
+ throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded"))
+ case let .drawing(drawing):
+ try container.encode("drawing", forKey: .type)
+ let drawingData = drawing.dataRepresentation()
+ try container.encode(drawingData, forKey: .drawing)
+ case .gif(_):
+ throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded"))
+ }
+ }
+
+ 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)
+ guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
+ throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier")
+ }
+ self = .asset(asset)
+ 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
+ }
+}
+
+extension AttachmentData: Equatable {
+ static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool {
+ switch (lhs, rhs) {
+ case let (.asset(a), .asset(b)):
+ return a.localIdentifier == b.localIdentifier
+ case let (.image(a, originalType: aType), .image(b, originalType: bType)):
+ return a == b && aType == bType
+ case let (.video(a), .video(b)):
+ return a == b
+ case let (.drawing(a), .drawing(b)):
+ return a == b
+ default:
+ return false
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift
new file mode 100644
index 00000000..cbbeec69
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift
@@ -0,0 +1,12 @@
+//
+// DismissMode.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/7/23.
+//
+
+import Foundation
+
+public enum DismissMode {
+ case cancel, post
+}
diff --git a/Tusker/Models/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift
similarity index 52%
rename from Tusker/Models/Draft.swift
rename to Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift
index 909d109c..0d24852f 100644
--- a/Tusker/Models/Draft.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift
@@ -1,55 +1,62 @@
//
// Draft.swift
-// Tusker
+// ComposeUI
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
+import Combine
import Pachyderm
-class Draft: Codable, ObservableObject {
- let id: UUID
+public class Draft: Codable, Identifiable, ObservableObject {
+ public let id: UUID
var lastModified: Date
- @Published var accountID: String
- @Published var text: String
- @Published var contentWarningEnabled: Bool
- @Published var contentWarning: String
- @Published var attachments: [CompositionAttachment]
- @Published var inReplyToID: String?
- @Published var visibility: Status.Visibility
- @Published var poll: Poll?
- @Published var localOnly: Bool
+ @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
- var hasContent: Bool {
+ public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
attachments.count > 0 ||
poll?.hasContent == true
}
- init(accountID: String) {
+ 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 = ""
- self.contentWarningEnabled = false
- self.contentWarning = ""
+ self.text = text
+ self.contentWarning = contentWarning
+ self.contentWarningEnabled = !contentWarning.isEmpty
self.attachments = []
- self.inReplyToID = nil
- self.visibility = Preferences.shared.defaultPostVisibility
- self.poll = nil
- self.localOnly = false
+ self.inReplyToID = inReplyToID
+ self.visibility = visibility
+ self.localOnly = localOnly
- self.initialText = ""
+ self.initialText = text
}
- required init(from decoder: Decoder) throws {
+ public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
@@ -59,16 +66,16 @@ class Draft: Codable, ObservableObject {
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([CompositionAttachment].self, forKey: .attachments)
+ self.attachments = try container.decode([DraftAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
- self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility)
+ 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)
}
- func encode(to encoder: Encoder) throws {
+ public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
@@ -89,13 +96,11 @@ class Draft: Codable, ObservableObject {
}
extension Draft: Equatable {
- static func ==(lhs: Draft, rhs: Draft) -> Bool {
+ public static func ==(lhs: Draft, rhs: Draft) -> Bool {
return lhs.id == rhs.id
}
}
-extension Draft: Identifiable {}
-
extension Draft {
enum CodingKeys: String, CodingKey {
case id
@@ -116,29 +121,29 @@ extension Draft {
}
extension Draft {
- class Poll: Codable, ObservableObject {
- @Published var options: [Option]
- @Published var multiple: Bool
- @Published var duration: TimeInterval
+ 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 }
}
- init() {
+ public init() {
self.options = [Option(""), Option("")]
self.multiple = false
self.duration = 24 * 60 * 60 // 1 day
}
- required init(from decoder: Decoder) throws {
+ 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)
}
- func encode(to encoder: Encoder) throws {
+ 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)
@@ -151,76 +156,22 @@ extension Draft {
case duration
}
- class Option: Identifiable, Codable, ObservableObject {
- let id = UUID()
- @Published var text: String
+ public class Option: Identifiable, Codable, ObservableObject {
+ public let id = UUID()
+ @Published public var text: String
init(_ text: String) {
self.text = text
}
- required init(from decoder: Decoder) throws {
+ public required init(from decoder: Decoder) throws {
self.text = try decoder.singleValueContainer().decode(String.self)
}
- func encode(to encoder: Encoder) throws {
+ public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(text)
}
}
}
}
-
-extension MastodonController {
-
- func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft {
- var acctsToMention = [String]()
-
- var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
- var localOnly = false
- var contentWarning = ""
-
- if let inReplyToID = inReplyToID,
- let inReplyTo = persistentContainer.status(for: inReplyToID) {
- acctsToMention.append(inReplyTo.account.acct)
- acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
- visibility = min(visibility, inReplyTo.visibility)
- localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly
-
- if !inReplyTo.spoilerText.isEmpty {
- switch Preferences.shared.contentWarningCopyMode {
- case .doNotCopy:
- break
- case .asIs:
- contentWarning = inReplyTo.spoilerText
- case .prependRe:
- if inReplyTo.spoilerText.lowercased().starts(with: "re:") {
- contentWarning = inReplyTo.spoilerText
- } else {
- contentWarning = "re: \(inReplyTo.spoilerText)"
- }
- }
- }
- }
- if let mentioningAcct = mentioningAcct {
- acctsToMention.append(mentioningAcct)
- }
- if let ownAccount = self.account {
- acctsToMention.removeAll(where: { $0 == ownAccount.acct })
- }
- acctsToMention = acctsToMention.uniques()
-
- let draft = Draft(accountID: accountInfo!.id)
- draft.inReplyToID = inReplyToID
- draft.text = acctsToMention.map { "@\($0) " }.joined()
- draft.initialText = draft.text
- draft.visibility = visibility
- draft.localOnly = localOnly
- draft.contentWarning = contentWarning
- draft.contentWarningEnabled = !contentWarning.isEmpty
-
- DraftsManager.shared.add(draft)
- return draft
- }
-
-}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift
new file mode 100644
index 00000000..7f8de0f2
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift
@@ -0,0 +1,117 @@
+//
+// 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
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift
new file mode 100644
index 00000000..5670f530
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift
@@ -0,0 +1,104 @@
+//
+// 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 {
+ 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)
+ }
+ }
+
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift
new file mode 100644
index 00000000..2357b019
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift
@@ -0,0 +1,95 @@
+//
+// StatusFormat.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 1/12/19.
+// Copyright © 2019 Shadowfacts. All rights reserved.
+//
+
+import UIKit
+import Pachyderm
+
+enum StatusFormat: Int, CaseIterable {
+ case bold, italics, strikethrough, code
+
+ func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
+ switch contentType {
+ case .plain:
+ return nil
+ case .markdown:
+ return Markdown.format(self)
+ case .html:
+ return HTML.format(self)
+ }
+ }
+
+ var imageName: String? {
+ switch self {
+ case .italics:
+ return "italic"
+ case .bold:
+ return "bold"
+ case .strikethrough:
+ return "strikethrough"
+ default:
+ return nil
+ }
+ }
+
+ var title: (String, [NSAttributedString.Key: Any])? {
+ if self == .code {
+ return (">", [.font: UIFont(name: "Menlo", size: 17)!])
+ } else {
+ return nil
+ }
+ }
+
+ var accessibilityLabel: String {
+ switch self {
+ case .italics:
+ return NSLocalizedString("Italics", comment: "italics text format accessibility label")
+ case .bold:
+ return NSLocalizedString("Bold", comment: "bold text format accessibility label")
+ case .strikethrough:
+ return NSLocalizedString("Strikethrough", comment: "strikethrough text format accessibility label")
+ case .code:
+ return NSLocalizedString("Code", comment: "code text format accessibility label")
+ }
+ }
+}
+
+typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int)
+
+fileprivate protocol FormatType {
+ static func format(_ format: StatusFormat) -> FormatInsertionResult
+}
+
+extension StatusFormat {
+ struct Markdown: FormatType {
+ static var formats: [StatusFormat: String] = [
+ .italics: "_",
+ .bold: "**",
+ .strikethrough: "~~",
+ .code: "`"
+ ]
+
+ static func format(_ format: StatusFormat) -> FormatInsertionResult {
+ let str = formats[format]!
+ return (str, str, str.count)
+ }
+ }
+
+ struct HTML: FormatType {
+ static var tags: [StatusFormat: String] = [
+ .italics: "em",
+ .bold: "strong",
+ .strikethrough: "del",
+ .code: "code"
+ ]
+
+ static func format(_ format: StatusFormat) -> FormatInsertionResult {
+ let tag = tags[format]!
+ return ("<\(tag)>", "\(tag)>", tag.count + 2)
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift b/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift
new file mode 100644
index 00000000..fe2e9316
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift
@@ -0,0 +1,33 @@
+//
+// OptionalObservedObject.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 4/15/23.
+//
+
+import SwiftUI
+import Combine
+
+@propertyWrapper
+struct OptionalObservedObject: DynamicProperty {
+ private class Republisher: ObservableObject {
+ var cancellable: AnyCancellable?
+ var wrapped: T? {
+ didSet {
+ cancellable?.cancel()
+ cancellable = wrapped?.objectWillChange
+ .receive(on: RunLoop.main)
+ .sink { [unowned self] _ in
+ self.objectWillChange.send()
+ }
+ }
+ }
+ }
+
+ @StateObject private var republisher = Republisher()
+ var wrappedValue: T?
+
+ func update() {
+ republisher.wrapped = wrappedValue
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift b/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift
new file mode 100644
index 00000000..f6eb4273
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift
@@ -0,0 +1,33 @@
+//
+// PKDrawing+Render.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 5/9/20.
+// Copyright © 2020 Shadowfacts. All rights reserved.
+//
+
+import UIKit
+import PencilKit
+
+extension PKDrawing {
+
+ func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
+ let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
+ var drawingImage: UIImage!
+ lightTraitCollection.performAsCurrent {
+ drawingImage = self.image(from: rect, scale: scale)
+ }
+
+ let imageRect = CGRect(origin: .zero, size: rect.size)
+ let format = UIGraphicsImageRendererFormat()
+ format.opaque = false
+ format.scale = scale
+ let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
+ return renderer.image { (context) in
+ UIColor.white.setFill()
+ context.fill(imageRect)
+ drawingImage.draw(in: imageRect)
+ }
+ }
+
+}
diff --git a/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift b/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift
similarity index 94%
rename from Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift
rename to Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift
index b2a68561..aa38ce18 100644
--- a/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift
@@ -1,5 +1,5 @@
//
-// ComposeTextViewCaretScrolling.swift
+// TextViewCaretScrolling.swift
// Tusker
//
// Created by Shadowfacts on 11/11/20.
@@ -8,11 +8,11 @@
import UIKit
-protocol ComposeTextViewCaretScrolling: AnyObject {
+protocol TextViewCaretScrolling: AnyObject {
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
}
-extension ComposeTextViewCaretScrolling {
+extension TextViewCaretScrolling {
func ensureCursorVisible(textView: UITextView) {
guard textView.isFirstResponder,
let range = textView.selectedTextRange,
diff --git a/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift b/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift
new file mode 100644
index 00000000..5e8d5607
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift
@@ -0,0 +1,183 @@
+//
+// UITextInput+Autocomplete.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/5/23.
+//
+
+import UIKit
+import SwiftUI
+
+extension UITextInput {
+ func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) {
+ guard let selectedTextRange,
+ let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
+ let text = self.text(in: wholeDocumentRange),
+ let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
+ return
+ }
+
+ let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument)
+
+ let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
+ let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
+
+ let insertSpace: Bool
+ if distanceToEnd > 0 {
+ let charAfterCursor = text[characterBeforeCursorIndex]
+ insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
+ } else {
+ insertSpace = true
+ }
+ let string = insertSpace ? string + " " : string
+
+ let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))!
+ let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)!
+ replace(lastWordRange, withText: string)
+
+ autocompleteState = updateAutocompleteState(permittedModes: permittedModes)
+
+ // keep the cursor at the same position in the text, immediately after what was inserted
+ // if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
+ let insertSpaceOffset = insertSpace ? 0 : 1
+ let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
+ self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition)
+ }
+
+ func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? {
+ guard let selectedTextRange,
+ let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
+ let text = self.text(in: wholeDocumentRange),
+ !text.isEmpty,
+ let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
+ return nil
+ }
+
+ let triggerChars = permittedModes.triggerChars
+
+ if lastWordStartIndex > text.startIndex {
+ // if the character before the "word" beginning is a valid part of a "word",
+ // we aren't able to autocomplete
+ let c = text[text.index(before: lastWordStartIndex)]
+ if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
+ return nil
+ }
+ }
+
+ let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start))
+
+ if lastWordStartIndex >= text.startIndex {
+ let lastWord = text[lastWordStartIndex.. (index: String.Index, foundFirstAtSign: Bool)? {
+ guard (self as? UIView)?.isFirstResponder == true,
+ let selectedTextRange,
+ selectedTextRange.isEmpty,
+ let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
+ let text = self.text(in: wholeDocumentRange),
+ !text.isEmpty else {
+ return nil
+ }
+
+ let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
+ let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
+
+ guard cursorIndex != text.startIndex else {
+ return nil
+ }
+
+ var lastWordStartIndex = text.index(before: cursorIndex)
+ var foundFirstAtSign = false
+ while true {
+ let c = text[lastWordStartIndex]
+
+ if !isPermittedForAutocomplete(c) {
+ if foundFirstAtSign {
+ if c != "@" {
+ // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
+ lastWordStartIndex = text.index(after: lastWordStartIndex)
+ }
+ break
+ } else {
+ if c == "@" {
+ foundFirstAtSign = true
+ } else if c != "." {
+ // periods are allowed for domain names in mentions
+ break
+ }
+ }
+ }
+
+ guard lastWordStartIndex > text.startIndex else {
+ break
+ }
+
+ lastWordStartIndex = text.index(before: lastWordStartIndex)
+ }
+
+ return (lastWordStartIndex, foundFirstAtSign)
+ }
+}
+
+enum AutocompleteState: Equatable {
+ case mention(String)
+ case emoji(String)
+ case hashtag(String)
+}
+
+struct AutocompleteModes: OptionSet {
+ static let mentions = AutocompleteModes(rawValue: 1 << 0)
+ static let hashtags = AutocompleteModes(rawValue: 1 << 2)
+ static let emojis = AutocompleteModes(rawValue: 1 << 3)
+
+ static let all: AutocompleteModes = [
+ .mentions,
+ .hashtags,
+ .emojis,
+ ]
+
+ let rawValue: Int
+
+ var triggerChars: [Character] {
+ var chars: [Character] = []
+ if contains(.mentions) {
+ chars.append("@")
+ }
+ if contains(.hashtags) {
+ chars.append("#")
+ }
+ if contains(.emojis) {
+ chars.append(":")
+ }
+ return chars
+ }
+}
+
+private func isPermittedForAutocomplete(_ c: Character) -> Bool {
+ return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift
new file mode 100644
index 00000000..418e36b9
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift
@@ -0,0 +1,20 @@
+//
+// View+ForwardsCompat.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/25/23.
+//
+
+import SwiftUI
+
+extension View {
+ @available(iOS, obsoleted: 16.0)
+ @ViewBuilder
+ func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
+ if #available(iOS 16.0, *) {
+ self.scrollDisabled(disabled)
+ } else {
+ self
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift
new file mode 100644
index 00000000..b08d953e
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift
@@ -0,0 +1,29 @@
+//
+// ViewController.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/4/23.
+//
+
+import SwiftUI
+import Combine
+
+public protocol ViewController: ObservableObject {
+ associatedtype ContentView: View
+
+ @ViewBuilder
+ var view: ContentView { get }
+}
+
+public struct ControllerView: View {
+ @StateObject private var controller: Controller
+
+ public init(controller: @escaping () -> Controller) {
+ self._controller = StateObject(wrappedValue: controller())
+ }
+
+ public var body: some View {
+ controller.view
+ .environmentObject(controller)
+ }
+}
diff --git a/Tusker/Screens/Compose/ComposeTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift
similarity index 54%
rename from Tusker/Screens/Compose/ComposeTextView.swift
rename to Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift
index b5ab6d59..4f7dd68b 100644
--- a/Tusker/Screens/Compose/ComposeTextView.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift
@@ -1,24 +1,20 @@
//
-// ComposeTextView.swift
-// Tusker
+// AttachmentDescriptionTextView.swift
+// ComposeUI
//
-// Created by Shadowfacts on 8/18/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
+// Created by Shadowfacts on 3/12/23.
//
import SwiftUI
-struct ComposeTextView: View {
+struct AttachmentDescriptionTextView: View {
@Binding private var text: String
private let placeholder: Text?
private let minHeight: CGFloat
- private var heightDidChange: ((CGFloat) -> Void)?
- private var backgroundColor = UIColor.secondarySystemBackground
-
@State private var height: CGFloat?
- init(text: Binding, placeholder: Text?, minHeight: CGFloat = 150) {
+ init(text: Binding, placeholder: Text?, minHeight: CGFloat) {
self._text = text
self.placeholder = placeholder
self.minHeight = minHeight
@@ -26,9 +22,7 @@ struct ComposeTextView: View {
var body: some View {
ZStack(alignment: .topLeading) {
- Color(backgroundColor)
-
- if text.isEmpty, let placeholder = placeholder {
+ if text.isEmpty, let placeholder {
placeholder
.font(.body)
.foregroundColor(.secondary)
@@ -40,46 +34,32 @@ struct ComposeTextView: View {
textDidChange: self.textDidChange,
font: .preferredFont(forTextStyle: .body)
)
- .frame(height: height ?? minHeight)
+ .frame(height: height ?? minHeight)
}
}
- private func textDidChange(textView: UITextView) {
+ private func textDidChange(_ textView: UITextView) {
height = max(minHeight, textView.contentSize.height)
- heightDidChange?(height!)
- }
-
- func heightDidChange(_ callback: @escaping (CGFloat) -> Void) -> Self {
- var copy = self
- copy.heightDidChange = callback
- return copy
- }
-
- func backgroundColor(_ color: UIColor) -> Self {
- var copy = self
- copy.backgroundColor = color
- return copy
}
}
-struct WrappedTextView: UIViewRepresentable {
+private struct WrappedTextView: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
- var textDidChange: ((UITextView) -> Void)?
- var font = UIFont.systemFont(ofSize: 20)
+ let textDidChange: ((UITextView) -> Void)
+ let font: UIFont
- @Environment(\.isEnabled) private var isEnabled: Bool
+ @Environment(\.isEnabled) private var isEnabled
func makeUIView(context: Context) -> UITextView {
- let textView = UITextView()
- textView.delegate = context.coordinator
- textView.isEditable = true
- textView.backgroundColor = .clear
- textView.font = font
- textView.adjustsFontForContentSizeCategory = true
- textView.textContainer.lineBreakMode = .byWordWrapping
- return textView
+ let view = UITextView()
+ view.delegate = context.coordinator
+ view.backgroundColor = .clear
+ view.font = font
+ view.adjustsFontForContentSizeCategory = true
+ view.textContainer.lineBreakMode = .byWordWrapping
+ return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
@@ -91,47 +71,42 @@ struct WrappedTextView: UIViewRepresentable {
// 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 {
- self.textDidChange?(uiView)
+ self.textDidChange(uiView)
}
}
func makeCoordinator() -> Coordinator {
- return Coordinator(text: $text, didChange: textDidChange)
+ Coordinator(text: $text, didChange: textDidChange)
}
- class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling {
+ class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
weak var textView: UITextView?
var text: Binding
- var didChange: ((UITextView) -> Void)?
+ var didChange: (UITextView) -> Void
var caretScrollPositionAnimator: UIViewPropertyAnimator?
- init(text: Binding, didChange: ((UITextView) -> Void)?) {
+ init(text: Binding, didChange: @escaping (UITextView) -> Void) {
self.text = text
self.didChange = didChange
super.init()
-
+
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
}
@objc private func keyboardDidShow() {
guard let textView,
- textView.isFirstResponder else { return }
+ textView.isFirstResponder else {
+ return
+ }
ensureCursorVisible(textView: textView)
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
- didChange?(textView)
+ didChange(textView)
ensureCursorVisible(textView: textView)
}
}
}
-
-
-//struct ComposeTextView_Previews: PreviewProvider {
-// static var previews: some View {
-// ComposeTextView()
-// }
-//}
diff --git a/Tusker/Screens/Compose/ComposeAttachmentImage.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift
similarity index 90%
rename from Tusker/Screens/Compose/ComposeAttachmentImage.swift
rename to Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift
index a0ffd2c6..caeaaac9 100644
--- a/Tusker/Screens/Compose/ComposeAttachmentImage.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift
@@ -1,6 +1,6 @@
//
-// ComposeAttachmentImage.swift
-// Tusker
+// AttachmentThumbnailView.swift
+// ComposeUI
//
// Created by Shadowfacts on 11/10/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
@@ -8,9 +8,10 @@
import SwiftUI
import Photos
+import TuskerComponents
-struct ComposeAttachmentImage: View {
- let attachment: CompositionAttachment
+struct AttachmentThumbnailView: View {
+ let attachment: DraftAttachment
let fullSize: Bool
@State private var gifData: Data? = nil
@@ -47,8 +48,8 @@ struct ComposeAttachmentImage: View {
private func loadImage() {
switch attachment.data {
- case let .image(image):
- self.image = image
+ case let .image(originalData, originalType: _):
+ self.image = UIImage(data: originalData)
case let .asset(asset):
let size: CGSize
if fullSize {
@@ -94,13 +95,13 @@ struct ComposeAttachmentImage: View {
private struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView
-
+
@State private var controller: GIFController
-
+
init(gifData: Data) {
self._controller = State(wrappedValue: GIFController(gifData: gifData))
}
-
+
func makeUIView(context: Context) -> GIFImageView {
let view = GIFImageView()
controller.attach(to: view)
@@ -110,13 +111,7 @@ private struct GIFViewWrapper: UIViewRepresentable {
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return view
}
-
+
func updateUIView(_ uiView: GIFImageView, context: Context) {
}
}
-
-struct ComposeAttachmentImage_Previews: PreviewProvider {
- static var previews: some View {
- ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false)
- }
-}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift
new file mode 100644
index 00000000..16ef5aed
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift
@@ -0,0 +1,41 @@
+//
+// CurrentAccountView.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/4/23.
+//
+
+import SwiftUI
+import Pachyderm
+import TuskerComponents
+
+struct CurrentAccountView: View {
+ let account: (any AccountProtocol)?
+ @EnvironmentObject private var controller: ComposeController
+
+ var body: some View {
+ HStack(alignment: .top) {
+ AvatarImageView(
+ url: account?.avatar,
+ size: 50,
+ style: controller.config.avatarStyle,
+ fetchAvatar: controller.fetchAvatar
+ )
+ .accessibilityHidden(true)
+
+ if let account {
+ VStack(alignment: .leading) {
+ controller.displayNameLabel(account, .title2, 24)
+ .lineLimit(1)
+
+ Text(verbatim: "@\(account.acct)")
+ .font(.body.weight(.light))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer()
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift
new file mode 100644
index 00000000..b5a0809c
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift
@@ -0,0 +1,137 @@
+//
+// EmojiTextField.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/5/23.
+//
+
+import SwiftUI
+
+struct EmojiTextField: UIViewRepresentable {
+ typealias UIViewType = UITextField
+
+ @EnvironmentObject private var controller: ComposeController
+ @Environment(\.colorScheme) private var colorScheme
+
+ @Binding var text: String
+ let placeholder: String
+ let maxLength: Int?
+ let becomeFirstResponder: Binding?
+ let focusNextView: Binding?
+
+ init(text: Binding, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = nil) {
+ self._text = text
+ self.placeholder = placeholder
+ self.maxLength = maxLength
+ self.becomeFirstResponder = becomeFirstResponder
+ self.focusNextView = focusNextView
+ }
+
+ func makeUIView(context: Context) -> UITextField {
+ let view = UITextField()
+ view.borderStyle = .roundedRect
+ view.font = .preferredFont(forTextStyle: .body)
+ view.adjustsFontForContentSizeCategory = true
+ view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
+ .foregroundColor: UIColor.secondaryLabel,
+ ])
+
+ context.coordinator.textField = view
+
+ view.delegate = context.coordinator
+ view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
+ view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
+
+ // otherwise when the text gets too wide it starts expanding the ComposeView
+ view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+ return view
+ }
+
+ func updateUIView(_ uiView: UITextField, context: Context) {
+ if text != uiView.text {
+ uiView.text = text
+ }
+
+ context.coordinator.text = $text
+ context.coordinator.maxLength = maxLength
+ context.coordinator.focusNextView = focusNextView
+
+ uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
+
+ if becomeFirstResponder?.wrappedValue == true {
+ DispatchQueue.main.async {
+ uiView.becomeFirstResponder()
+ becomeFirstResponder!.wrappedValue = false
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
+ }
+
+ class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
+ let controller: ComposeController
+ var text: Binding
+ var focusNextView: Binding?
+ var maxLength: Int?
+
+ @Published var autocompleteState: AutocompleteState?
+ var autocompleteStatePublisher: Published.Publisher { $autocompleteState }
+
+ weak var textField: UITextField?
+
+ init(controller: ComposeController, text: Binding, focusNextView: Binding?, maxLength: Int? = nil) {
+ self.controller = controller
+ self.text = text
+ self.focusNextView = focusNextView
+ self.maxLength = maxLength
+ }
+
+ @objc func didChange(_ textField: UITextField) {
+ text.wrappedValue = textField.text ?? ""
+ }
+
+ @objc func returnKeyPressed() {
+ focusNextView?.wrappedValue = true
+ }
+
+ func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
+ if let maxLength {
+ return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
+ } else {
+ return true
+ }
+ }
+
+ func textFieldDidBeginEditing(_ textField: UITextField) {
+ controller.currentInput = self
+ autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
+ }
+
+ func textFieldDidEndEditing(_ textField: UITextField) {
+ controller.currentInput = nil
+ autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
+ }
+
+ func textFieldDidChangeSelection(_ textField: UITextField) {
+ autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
+ }
+
+ // MARK: ComposeInput
+
+ var toolbarElements: [ToolbarElement] { [.emojiPicker] }
+
+ func applyFormat(_ format: StatusFormat) {
+ }
+
+ func beginAutocompletingEmoji() {
+ textField?.insertText(":")
+ }
+
+ func autocomplete(with string: String) {
+ textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState)
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift
new file mode 100644
index 00000000..9786f606
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift
@@ -0,0 +1,34 @@
+//
+// HeaderView.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/4/23.
+//
+
+import SwiftUI
+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 }
+
+ var body: some View {
+ HStack(alignment: .top) {
+ CurrentAccountView(account: controller.currentAccount)
+ .accessibilitySortPriority(1)
+
+ Spacer()
+
+ Text(verbatim: charsRemaining.description)
+ .foregroundColor(charsRemaining < 0 ? .red : .secondary)
+ .font(Font.body.monospacedDigit())
+ .accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
+ // this should come first, so VO users can back to it from the main compose text view
+ .accessibilitySortPriority(0)
+ }.frame(height: 50)
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift
new file mode 100644
index 00000000..079584c6
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift
@@ -0,0 +1,293 @@
+//
+// MainTextView.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/6/23.
+//
+
+import SwiftUI
+
+struct MainTextView: View {
+ @EnvironmentObject private var controller: ComposeController
+ @EnvironmentObject private var draft: Draft
+ @Environment(\.colorScheme) private var colorScheme
+ @ScaledMetric private var fontSize = 20
+
+ @State private var hasFirstAppeared = false
+ @State private var height: CGFloat?
+ private let minHeight: CGFloat = 150
+ private var effectiveHeight: CGFloat { height ?? minHeight }
+
+ var config: ComposeUIConfig {
+ controller.config
+ }
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground)
+
+ if draft.text.isEmpty {
+ ControllerView(controller: { PlaceholderController() })
+ .font(.system(size: fontSize))
+ .foregroundColor(.secondary)
+ .offset(x: 4, y: 8)
+ .accessibilityHidden(true)
+ }
+
+ MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, textDidChange: textDidChange)
+ }
+ .frame(height: effectiveHeight)
+ .onAppear(perform: becomeFirstResponderOnFirstAppearance)
+ }
+
+ private func becomeFirstResponderOnFirstAppearance() {
+ if !hasFirstAppeared {
+ hasFirstAppeared = true
+ controller.mainComposeTextViewBecomeFirstResponder = true
+ }
+ }
+
+ private func textDidChange(textView: UITextView) {
+ height = max(textView.contentSize.height, minHeight)
+ }
+}
+
+fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
+ typealias UIViewType = UITextView
+
+ @Binding var text: String
+ @Binding var becomeFirstResponder: Bool
+ let textDidChange: (UITextView) -> Void
+
+ @EnvironmentObject private var controller: ComposeController
+ @Environment(\.isEnabled) private var isEnabled: Bool
+
+ func makeUIView(context: Context) -> UITextView {
+ let textView = WrappedTextView(composeController: controller)
+ context.coordinator.textView = textView
+ textView.delegate = context.coordinator
+ textView.isEditable = true
+ textView.backgroundColor = .clear
+ textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
+ textView.adjustsFontForContentSizeCategory = true
+ textView.textContainer.lineBreakMode = .byWordWrapping
+ return textView
+ }
+
+ func updateUIView(_ uiView: UITextView, context: Context) {
+ if text != uiView.text {
+ context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true
+ uiView.text = text
+ }
+
+ uiView.isEditable = isEnabled
+ uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
+
+ context.coordinator.text = $text
+
+ // 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 {
+ textDidChange(uiView)
+
+ if becomeFirstResponder {
+ // calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
+ uiView.becomeFirstResponder()
+ // can't update @State vars during the SwiftUI update
+ becomeFirstResponder = false
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(controller: controller, text: $text, textDidChange: textDidChange)
+ }
+
+ class WrappedTextView: UITextView {
+ private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
+ private let composeController: ComposeController
+
+ init(composeController: ComposeController) {
+ self.composeController = composeController
+ super.init(frame: .zero, textContainer: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError()
+ }
+
+ override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
+ if formattingActions.contains(action) {
+ return composeController.config.contentType != .plain
+ }
+ return super.canPerformAction(action, withSender: sender)
+ }
+
+ override func toggleBoldface(_ sender: Any?) {
+ (delegate as! Coordinator).applyFormat(.bold)
+ }
+
+ override func toggleItalics(_ sender: Any?) {
+ (delegate as! Coordinator).applyFormat(.italics)
+ }
+
+ override func validate(_ command: UICommand) {
+ super.validate(command)
+
+ if formattingActions.contains(command.action),
+ composeController.config.contentType != .plain {
+ command.attributes.remove(.disabled)
+ }
+ }
+
+ override func paste(_ sender: Any?) {
+ // we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion
+ // and things like URLs end up pasting as attachments
+ if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) {
+ composeController.paste(itemProviders: UIPasteboard.general.itemProviders)
+ } else {
+ super.paste(sender)
+ }
+ }
+ }
+
+ class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling {
+ weak var textView: UITextView?
+
+ let controller: ComposeController
+ var text: Binding
+ let textDidChange: (UITextView) -> Void
+
+ var caretScrollPositionAnimator: UIViewPropertyAnimator?
+
+ @Published var autocompleteState: AutocompleteState?
+ var autocompleteStatePublisher: Published.Publisher { $autocompleteState }
+ var skipNextSelectionChangedAutocompleteUpdate = false
+
+ init(controller: ComposeController, text: Binding, textDidChange: @escaping (UITextView) -> Void) {
+ self.controller = controller
+ self.text = text
+ self.textDidChange = textDidChange
+
+ super.init()
+
+ NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
+ }
+
+ @objc private func keyboardDidShow() {
+ guard let textView,
+ textView.isFirstResponder else {
+ return
+ }
+ ensureCursorVisible(textView: textView)
+ }
+
+ // MARK: UITextViewDelegate
+
+ func textViewDidChange(_ textView: UITextView) {
+ text.wrappedValue = textView.text
+ textDidChange(textView)
+
+ ensureCursorVisible(textView: textView)
+ }
+
+ func textViewDidBeginEditing(_ textView: UITextView) {
+ controller.currentInput = self
+ updateAutocompleteState()
+ }
+
+ func textViewDidEndEditing(_ textView: UITextView) {
+ controller.currentInput = nil
+ updateAutocompleteState()
+ }
+
+ func textViewDidChangeSelection(_ textView: UITextView) {
+ if skipNextSelectionChangedAutocompleteUpdate {
+ skipNextSelectionChangedAutocompleteUpdate = false
+ } else {
+ updateAutocompleteState()
+ }
+ }
+
+ func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
+ var actions = suggestedActions
+ if controller.config.contentType != .plain,
+ let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
+ if range.length > 0 {
+ let formatMenu = suggestedActions[index] as! UIMenu
+ let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
+ var image: UIImage?
+ if let imageName = fmt.imageName {
+ image = UIImage(systemName: imageName)
+ }
+ return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
+ self?.applyFormat(fmt)
+ }
+ })
+ actions[index] = newFormatMenu
+ } else {
+ actions.remove(at: index)
+ }
+ }
+ if range.length == 0 {
+ actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
+ self?.controller.shouldEmojiAutocompletionBeginExpanded = true
+ self?.beginAutocompletingEmoji()
+ }))
+ }
+ return UIMenu(children: actions)
+ }
+
+ // MARK: ComposeInput
+
+ var toolbarElements: [ToolbarElement] {
+ [.emojiPicker, .formattingButtons]
+ }
+
+ func autocomplete(with string: String) {
+ textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
+ }
+
+ func applyFormat(_ format: StatusFormat) {
+ guard let textView,
+ textView.isFirstResponder,
+ let insertionResult = format.insertionResult(for: controller.config.contentType) else {
+ return
+ }
+
+ let currentSelectedRange = textView.selectedRange
+ if currentSelectedRange.length == 0 {
+ textView.insertText(insertionResult.prefix + insertionResult.suffix)
+ textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
+ } else {
+ let start = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound)
+ let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound)
+ let selectedText = textView.text.utf16[start.. 0 {
+ let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound))
+ insertSpace = !text[characterBeforeCursorIndex].isWhitespace
+ }
+ textView.insertText((insertSpace ? " " : "") + ":")
+ }
+
+ private func updateAutocompleteState() {
+ guard let textView else {
+ autocompleteState = nil
+ return
+ }
+ autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
+ }
+
+ }
+}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift
new file mode 100644
index 00000000..10e4b198
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift
@@ -0,0 +1,75 @@
+//
+// PollOptionView.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/25/23.
+//
+
+import SwiftUI
+
+struct PollOptionView: View {
+ @EnvironmentObject private var controller: PollController
+ @EnvironmentObject private var poll: Draft.Poll
+ @ObservedObject private var option: Draft.Poll.Option
+ let remove: () -> Void
+
+ init(option: Draft.Poll.Option, remove: @escaping () -> Void) {
+ self.option = option
+ self.remove = remove
+ }
+
+ private var optionIndex: Int {
+ poll.options.firstIndex(where: { $0.id == option.id }) ?? 0
+ }
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
+ .animation(.default, value: poll.multiple)
+
+ textField
+
+ Button(action: remove) {
+ Image(systemName: "minus.circle.fill")
+ }
+ .accessibilityLabel("Remove option")
+ .buttonStyle(.plain)
+ .foregroundColor(poll.options.count == 1 ? .gray : .red)
+ .disabled(poll.options.count == 1)
+ .hoverEffect()
+ }
+ }
+
+ private var textField: some View {
+ let placeholder = "Option \(optionIndex + 1)"
+ let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars
+ return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength)
+ }
+
+ struct Checkbox: View {
+ private let radiusFraction: CGFloat
+ private let size: CGFloat = 20
+ private let innerSize: CGFloat
+ private let background: Color
+
+ init(radiusFraction: CGFloat, background: Color) {
+ self.radiusFraction = radiusFraction
+ self.innerSize = self.size - 4
+ self.background = background
+ }
+
+ var body: some View {
+ ZStack {
+ Rectangle()
+ .foregroundColor(.gray)
+ .frame(width: size, height: size)
+ .cornerRadius(radiusFraction * size)
+
+ Rectangle()
+ .foregroundColor(background)
+ .frame(width: innerSize, height: innerSize)
+ .cornerRadius(radiusFraction * innerSize)
+ }
+ }
+ }
+}
diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift
similarity index 75%
rename from Tusker/Screens/Compose/ComposeReplyView.swift
rename to Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift
index 0ada5abe..91706952 100644
--- a/Tusker/Screens/Compose/ComposeReplyView.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift
@@ -1,41 +1,41 @@
//
-// ComposeReplyView.swift
-// Tusker
+// ReplyStatusView.swift
+// ComposeUI
//
-// Created by Shadowfacts on 8/22/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
+// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
+import Pachyderm
+import TuskerComponents
-struct ComposeReplyView: View {
- let status: StatusMO
+struct ReplyStatusView: View {
+ let status: any StatusProtocol
let rowTopInset: CGFloat
let globalFrameOutsideList: CGRect
+ @EnvironmentObject private var controller: ComposeController
@State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat?
- @ObservedObject private var preferences = Preferences.shared
-
private let horizSpacing: CGFloat = 8
var body: some View {
HStack(alignment: .top, spacing: horizSpacing) {
GeometryReader(content: self.replyAvatarImage)
.frame(width: 50)
-
+
VStack(alignment: .leading, spacing: 0) {
HStack {
- AccountDisplayNameLabel(account: status.account, textStyle: .body, emojiSize: 17)
+ controller.displayNameLabel(status.account, .body, 17)
.lineLimit(1)
.layoutPriority(1)
-
+
Text(verbatim: "@\(status.account.acct)")
- .font(.system(size: 17, weight: .light))
+ .font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
-
+
Spacer()
}
.background(GeometryReader { proxy in
@@ -45,8 +45,8 @@ struct ComposeReplyView: View {
displayNameHeight = newValue
}
})
-
- ComposeReplyContentView(status: status) { newHeight in
+
+ controller.replyContentView(status) { newHeight in
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
// and it ends up partially behind the header
DispatchQueue.main.async {
@@ -76,13 +76,16 @@ struct ComposeReplyView: View {
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset)
- return ComposeAvatarImageView(url: status.account.avatar)
- .frame(width: 50, height: 50)
- .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
- .offset(x: 0, y: offset)
- .accessibilityHidden(true)
+ return AvatarImageView(
+ url: status.account.avatar,
+ size: 50,
+ style: controller.config.avatarStyle,
+ fetchAvatar: controller.fetchAvatar
+ )
+ .offset(x: 0, y: offset)
+ .accessibilityHidden(true)
}
-
+
}
private struct DisplayNameHeightPrefKey: PreferenceKey {
@@ -91,9 +94,3 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
value = nextValue()
}
}
-
-//struct ComposeReplyView_Previews: PreviewProvider {
-// static var previews: some View {
-// ComposeReplyView()
-// }
-//}
diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift
new file mode 100644
index 00000000..a6037682
--- /dev/null
+++ b/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift
@@ -0,0 +1,29 @@
+//
+// WrappedProgressView.swift
+// Tusker
+//
+// Created by Shadowfacts on 8/30/20.
+// Copyright © 2020 Shadowfacts. All rights reserved.
+//
+
+import SwiftUI
+
+struct WrappedProgressView: UIViewRepresentable {
+ typealias UIViewType = UIProgressView
+
+ let value: Int
+ let total: Int
+
+ func makeUIView(context: Context) -> UIProgressView {
+ return UIProgressView(progressViewStyle: .bar)
+ }
+
+ func updateUIView(_ uiView: UIProgressView, context: Context) {
+ if total > 0 {
+ let progress = Float(value) / Float(total)
+ uiView.setProgress(progress, animated: true)
+ } else {
+ uiView.setProgress(0, animated: true)
+ }
+ }
+}
diff --git a/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift b/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift
new file mode 100644
index 00000000..c00916b4
--- /dev/null
+++ b/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift
@@ -0,0 +1,25 @@
+//
+// FuzzyMatcherTests.swift
+// ComposeUITests
+//
+// Created by Shadowfacts on 10/11/20.
+// Copyright © 2020 Shadowfacts. All rights reserved.
+//
+
+import XCTest
+@testable import ComposeUI
+
+class FuzzyMatcherTests: XCTestCase {
+
+ func testExample() throws {
+ XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6)
+ XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4)
+ XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6)
+
+ XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2)
+ XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1)
+
+ XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score)
+ }
+
+}
diff --git a/Packages/InstanceFeatures/.gitignore b/Packages/InstanceFeatures/.gitignore
new file mode 100644
index 00000000..3b298120
--- /dev/null
+++ b/Packages/InstanceFeatures/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
+DerivedData/
+.swiftpm/config/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/Packages/InstanceFeatures/Package.resolved b/Packages/InstanceFeatures/Package.resolved
new file mode 100644
index 00000000..a9ae5ab1
--- /dev/null
+++ b/Packages/InstanceFeatures/Package.resolved
@@ -0,0 +1,23 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-url",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/karwa/swift-url.git",
+ "state" : {
+ "branch" : "main",
+ "revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Packages/InstanceFeatures/Package.swift b/Packages/InstanceFeatures/Package.swift
new file mode 100644
index 00000000..1927a00b
--- /dev/null
+++ b/Packages/InstanceFeatures/Package.swift
@@ -0,0 +1,31 @@
+// swift-tools-version: 5.7
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "InstanceFeatures",
+ platforms: [
+ .iOS(.v15),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, and make them visible to other packages.
+ .library(
+ name: "InstanceFeatures",
+ targets: ["InstanceFeatures"]),
+ ],
+ dependencies: [
+ // Dependencies declare other packages that this package depends on.
+ .package(path: "../Pachyderm"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package. A target can define a module or a test suite.
+ // Targets can depend on other targets in this package, and on products in packages this package depends on.
+ .target(
+ name: "InstanceFeatures",
+ dependencies: ["Pachyderm"]),
+ .testTarget(
+ name: "InstanceFeaturesTests",
+ dependencies: ["InstanceFeatures"]),
+ ]
+)
diff --git a/Packages/InstanceFeatures/README.md b/Packages/InstanceFeatures/README.md
new file mode 100644
index 00000000..d9dc107e
--- /dev/null
+++ b/Packages/InstanceFeatures/README.md
@@ -0,0 +1,3 @@
+# InstanceFeatures
+
+A description of this package.
diff --git a/Tusker/API/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift
similarity index 80%
rename from Tusker/API/InstanceFeatures.swift
rename to Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift
index 23e3700e..6ddf11dc 100644
--- a/Tusker/API/InstanceFeatures.swift
+++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift
@@ -7,17 +7,23 @@
//
import Foundation
+import Combine
import Pachyderm
-import Sentry
-struct InstanceFeatures {
+public class InstanceFeatures: ObservableObject {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
- private var instanceType: InstanceType = .mastodon(.vanilla, nil)
- private(set) var maxStatusChars = 500
+ private let _featuresUpdated = PassthroughSubject()
+ public var featuresUpdated: some Publisher { _featuresUpdated }
- var localOnlyPosts: Bool {
+ @Published private var instanceType: InstanceType = .mastodon(.vanilla, nil)
+ @Published public private(set) var maxStatusChars = 500
+ @Published public private(set) var charsReservedPerURL = 23
+ @Published public private(set) var maxPollOptionChars: Int?
+ @Published public private(set) var maxPollOptionsCount: Int?
+
+ public var localOnlyPosts: Bool {
switch instanceType {
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
return true
@@ -26,19 +32,19 @@ struct InstanceFeatures {
}
}
- var mastodonAttachmentRestrictions: Bool {
+ public var mastodonAttachmentRestrictions: Bool {
instanceType.isMastodon
}
- var pollsAndAttachments: Bool {
+ public var pollsAndAttachments: Bool {
instanceType.isPleroma
}
- var boostToOriginalAudience: Bool {
+ public var boostToOriginalAudience: Bool {
instanceType.isPleroma || instanceType.isMastodon
}
- var profilePinnedStatuses: Bool {
+ public var profilePinnedStatuses: Bool {
switch instanceType {
case .pixelfed:
return false
@@ -47,24 +53,24 @@ struct InstanceFeatures {
}
}
- var trends: Bool {
+ public var trends: Bool {
instanceType.isMastodon
}
- var profileSuggestions: Bool {
+ public var profileSuggestions: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
}
- var trendingStatusesAndLinks: Bool {
+ public var trendingStatusesAndLinks: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
}
- var reblogVisibility: Bool {
+ public var reblogVisibility: Bool {
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
}
- var probablySupportsMarkdown: Bool {
+ public var probablySupportsMarkdown: Bool {
switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _):
return true
@@ -73,7 +79,7 @@ struct InstanceFeatures {
}
}
- var needsLocalOnlyEmojiHack: Bool {
+ public var needsLocalOnlyEmojiHack: Bool {
if case .mastodon(.glitch, _) = instanceType {
return true
} else {
@@ -81,7 +87,7 @@ struct InstanceFeatures {
}
}
- var needsWideColorGamutHack: Bool {
+ public var needsWideColorGamutHack: Bool {
if case .mastodon(_, .some(let version)) = instanceType {
return version < Version(4, 0, 0)
} else {
@@ -89,23 +95,26 @@ struct InstanceFeatures {
}
}
- var canFollowHashtags: Bool {
+ public var canFollowHashtags: Bool {
hasMastodonVersion(4, 0, 0)
}
- var filtersV2: Bool {
+ public var filtersV2: Bool {
hasMastodonVersion(4, 0, 0)
}
- var notificationsAllowedTypes: Bool {
+ public var notificationsAllowedTypes: Bool {
hasMastodonVersion(3, 5, 0)
}
- var pollVotersCount: Bool {
+ public var pollVotersCount: Bool {
instanceType.isMastodon
}
- mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
+ public init() {
+ }
+
+ public func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased()
if ver.contains("glitch") {
instanceType = .mastodon(.glitch, Version(string: ver))
@@ -149,11 +158,16 @@ struct InstanceFeatures {
}
maxStatusChars = instance.maxStatusCharacters ?? 500
+ charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
+ if let pollsConfig = instance.pollsConfiguration {
+ maxPollOptionChars = pollsConfig.maxCharactersPerOption
+ maxPollOptionsCount = pollsConfig.maxOptions
+ }
- setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
+ _featuresUpdated.send()
}
- func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
+ public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if case .mastodon(_, .some(let version)) = instanceType {
return version >= Version(major, minor, patch)
} else {
@@ -259,19 +273,3 @@ extension InstanceFeatures {
}
}
}
-
-private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
- let crumb = Breadcrumb(level: .info, category: "MastodonController")
- crumb.data = [
- "instance": [
- "version": instance.version
- ],
- ]
- if let nodeInfo {
- crumb.data!["nodeInfo"] = [
- "software": nodeInfo.software.name,
- "version": nodeInfo.software.version,
- ]
- }
- SentrySDK.addBreadcrumb(crumb)
-}
diff --git a/TuskerTests/VersionTests.swift b/Packages/InstanceFeatures/Tests/InstanceFeaturesTests/VersionTests.swift
similarity index 91%
rename from TuskerTests/VersionTests.swift
rename to Packages/InstanceFeatures/Tests/InstanceFeaturesTests/VersionTests.swift
index a8f726f9..bdfcfde1 100644
--- a/TuskerTests/VersionTests.swift
+++ b/Packages/InstanceFeatures/Tests/InstanceFeaturesTests/VersionTests.swift
@@ -1,13 +1,13 @@
//
// VersionTests.swift
-// TuskerTests
+// InstanceFeaturesTests
//
// Created by Shadowfacts on 4/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import XCTest
-@testable import Tusker
+@testable import InstanceFeatures
class VersionTests: XCTestCase {
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift
index f8e64c6a..7165f5ec 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift
@@ -384,7 +384,7 @@ public class Client {
media: [Attachment]? = nil,
sensitive: Bool? = nil,
spoilerText: String? = nil,
- visibility: Status.Visibility? = nil,
+ visibility: Visibility? = nil,
language: String? = nil,
pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil,
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift
index e5c98bd4..85b2c877 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift
@@ -64,6 +64,6 @@ extension Hashtag: Equatable, Hashable {
}
public func hash(into hasher: inout Hasher) {
- hasher.combine(url)
+ hasher.combine(name)
}
}
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift
index 6b1be094..c3f26ae7 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift
@@ -9,7 +9,6 @@
import Foundation
public protocol AccountProtocol {
- associatedtype Account: AccountProtocol
var id: String { get }
var username: String { get }
@@ -27,7 +26,7 @@ public protocol AccountProtocol {
var moved: Bool? { get }
var bot: Bool? { get }
- var movedTo: Account? { get }
+ var movedTo: Self? { get }
var emojis: [Emoji] { get }
var fields: [Pachyderm.Account.Field] { get }
}
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift
new file mode 100644
index 00000000..bfe47579
--- /dev/null
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift
@@ -0,0 +1,21 @@
+//
+// RelationshipProtocol.swift
+// Pachyderm
+//
+// Created by Shadowfacts on 3/26/23.
+//
+
+import Foundation
+
+public protocol RelationshipProtocol {
+ var accountID: String { get }
+ var following: Bool { get }
+ var followedBy: Bool { get }
+ var blocking: Bool { get }
+ var muting: Bool { get }
+ var mutingNotifications: Bool { get }
+ var followRequested: Bool { get }
+ var domainBlocking: Bool { get }
+ var showingReblogs: Bool { get }
+ var endorsed: Bool { get }
+}
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift
index afd49fde..e0a41264 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift
@@ -25,7 +25,7 @@ public protocol StatusProtocol {
// var favourited: Bool { get }
var sensitive: Bool { get }
var spoilerText: String { get }
- var visibility: Pachyderm.Status.Visibility { get }
+ var visibility: Visibility { get }
var applicationName: String? { get }
var pinned: Bool? { get }
var bookmarked: Bool? { get }
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift
index 66245702..96c1d03b 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift
@@ -8,8 +8,8 @@
import Foundation
-public struct Relationship: Decodable, Sendable {
- public let id: String
+public struct Relationship: RelationshipProtocol, Decodable, Sendable {
+ public let accountID: String
public let following: Bool
public let followedBy: Bool
public let blocking: Bool
@@ -18,7 +18,21 @@ public struct Relationship: Decodable, Sendable {
public let followRequested: Bool
public let domainBlocking: Bool
public let showingReblogs: Bool
- public let endorsed: Bool?
+ public let endorsed: Bool
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.accountID = try container.decode(String.self, forKey: .id)
+ self.following = try container.decode(Bool.self, forKey: .following)
+ self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
+ self.blocking = try container.decode(Bool.self, forKey: .blocking)
+ self.muting = try container.decode(Bool.self, forKey: .muting)
+ self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
+ self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
+ self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
+ self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
+ self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
+ }
private enum CodingKeys: String, CodingKey {
case id
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift
index 6941e09a..133a5d47 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift
@@ -63,7 +63,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
- if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) {
+ if let visibility = try? container.decode(Visibility.self, forKey: .visibility) {
self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility),
@@ -187,13 +187,4 @@ public final class Status: StatusProtocol, Decodable, Sendable {
}
}
-extension Status {
- public enum Visibility: String, Codable, CaseIterable, Sendable {
- case `public`
- case unlisted
- case `private`
- case direct
- }
-}
-
extension Status: Identifiable {}
diff --git a/Tusker/Extensions/Visibility+Helpers.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Visibility.swift
similarity index 81%
rename from Tusker/Extensions/Visibility+Helpers.swift
rename to Packages/Pachyderm/Sources/Pachyderm/Model/Visibility.swift
index 0c8d5b94..489b1f2e 100644
--- a/Tusker/Extensions/Visibility+Helpers.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Visibility.swift
@@ -1,16 +1,33 @@
//
-// Visibility+String.swift
-// Tusker
+// Visibility.swift
+// Pachyderm
//
-// Created by Shadowfacts on 8/29/18.
-// Copyright © 2018 Shadowfacts. All rights reserved.
+// Created by Shadowfacts on 3/7/23.
//
-import Pachyderm
-import UIKit
+import Foundation
-extension Status.Visibility {
+public enum Visibility: String, Sendable, Codable, CaseIterable, Comparable {
+ case `public`
+ case unlisted
+ case `private`
+ case direct
+ public static func < (lhs: Visibility, rhs: Visibility) -> Bool {
+ switch (lhs, rhs) {
+ case (.direct, .public), (.private, .public), (.unlisted, .public):
+ return true
+ case (.direct, .unlisted), (.private, .unlisted):
+ return true
+ case (.direct, .private):
+ return true
+ default:
+ return false
+ }
+ }
+}
+
+public extension Visibility {
var displayName: String {
switch self {
case .public:
@@ -62,20 +79,4 @@ extension Status.Visibility {
return "envelope"
}
}
-
-}
-
-extension Status.Visibility: Comparable {
- public static func < (lhs: Pachyderm.Status.Visibility, rhs: Pachyderm.Status.Visibility) -> Bool {
- switch (lhs, rhs) {
- case (.direct, .public), (.private, .public), (.unlisted, .public):
- return true
- case (.direct, .unlisted), (.private, .unlisted):
- return true
- case (.direct, .private):
- return true
- default:
- return false
- }
- }
}
diff --git a/Packages/TuskerComponents/.gitignore b/Packages/TuskerComponents/.gitignore
new file mode 100644
index 00000000..3b298120
--- /dev/null
+++ b/Packages/TuskerComponents/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
+DerivedData/
+.swiftpm/config/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/Packages/TuskerComponents/Package.swift b/Packages/TuskerComponents/Package.swift
new file mode 100644
index 00000000..a0bd020b
--- /dev/null
+++ b/Packages/TuskerComponents/Package.swift
@@ -0,0 +1,31 @@
+// swift-tools-version: 5.7
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "TuskerComponents",
+ platforms: [
+ .iOS(.v15),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, and make them visible to other packages.
+ .library(
+ name: "TuskerComponents",
+ targets: ["TuskerComponents"]),
+ ],
+ dependencies: [
+ // Dependencies declare other packages that this package depends on.
+ // .package(url: /* package url */, from: "1.0.0"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package. A target can define a module or a test suite.
+ // Targets can depend on other targets in this package, and on products in packages this package depends on.
+ .target(
+ name: "TuskerComponents",
+ dependencies: []),
+ .testTarget(
+ name: "TuskerComponentsTests",
+ dependencies: ["TuskerComponents"]),
+ ]
+)
diff --git a/Packages/TuskerComponents/README.md b/Packages/TuskerComponents/README.md
new file mode 100644
index 00000000..11206816
--- /dev/null
+++ b/Packages/TuskerComponents/README.md
@@ -0,0 +1,3 @@
+# TuskerComponents
+
+A description of this package.
diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift
new file mode 100644
index 00000000..df10e99f
--- /dev/null
+++ b/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift
@@ -0,0 +1,70 @@
+//
+// AbbreviatedTimeAgoFormatStyle.swift
+//
+//
+// Created by Shadowfacts on 4/9/23.
+//
+
+import Foundation
+
+public struct AbbreviatedTimeAgoFormatStyle: FormatStyle {
+ public typealias FormatInput = Date
+ public typealias FormatOutput = String
+
+ public func format(_ value: Date) -> String {
+ let (amount, component) = timeAgo(value: value)
+
+ switch component {
+ case .year:
+ return "\(amount)y"
+ case .month:
+ return "\(amount)mo"
+ case .weekOfYear:
+ return "\(amount)w"
+ case .day:
+ return "\(amount)d"
+ case .hour:
+ return "\(amount)h"
+ case .minute:
+ return "\(amount)m"
+ case .second:
+ if amount >= 3 {
+ return "\(amount)s"
+ } else {
+ return "Now"
+ }
+ default:
+ fatalError("Unexpected component: \(component)")
+ }
+ }
+
+ private static let unitFlags = Set([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
+
+ private func timeAgo(value: Date) -> (Int, Calendar.Component) {
+ let calendar = NSCalendar.current
+ let components = calendar.dateComponents(Self.unitFlags, from: value, to: Date())
+
+ if components.year! >= 1 {
+ return (components.year!, .year)
+ } else if components.month! >= 1 {
+ return (components.month!, .month)
+ } else if components.weekOfYear! >= 1 {
+ return (components.weekOfYear!, .weekOfYear)
+ } else if components.day! >= 1 {
+ return (components.day!, .day)
+ } else if components.hour! >= 1 {
+ return (components.hour!, .hour)
+ } else if components.minute! >= 1 {
+ return (components.minute!, .minute)
+ } else {
+ return (components.second!, .second)
+ }
+ }
+
+}
+
+public extension FormatStyle where Self == AbbreviatedTimeAgoFormatStyle {
+ static var abbreviatedTimeAgo: Self {
+ Self()
+ }
+}
diff --git a/Tusker/Views/AlertWithData.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift
similarity index 83%
rename from Tusker/Views/AlertWithData.swift
rename to Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift
index 226ab9f2..8b3b6238 100644
--- a/Tusker/Views/AlertWithData.swift
+++ b/Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift
@@ -1,6 +1,6 @@
//
// AlertWithData.swift
-// Tusker
+// TuskerComponents
//
// Created by Shadowfacts on 11/9/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
@@ -39,7 +39,7 @@ struct AlertWithData: ViewModifier {
}
extension View {
- func alertWithData(_ title: LocalizedStringKey, data: Binding, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
+ public func alertWithData(_ title: LocalizedStringKey, data: Binding, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
}
}
diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift
new file mode 100644
index 00000000..868c4d07
--- /dev/null
+++ b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift
@@ -0,0 +1,65 @@
+//
+// AvatarImageView.swift
+// ComposeUI
+//
+// Created by Shadowfacts on 3/4/23.
+//
+
+import SwiftUI
+
+public struct AvatarImageView: View {
+ public typealias FetchAvatar = (URL) async -> UIImage?
+
+ let url: URL?
+ let size: CGFloat
+ let style: Style
+ let fetchAvatar: FetchAvatar
+ @State private var image: UIImage?
+
+ public init(url: URL?, size: CGFloat, style: Style, fetchAvatar: @escaping FetchAvatar) {
+ self.url = url
+ self.size = size
+ self.style = style
+ self.fetchAvatar = fetchAvatar
+ }
+
+ public var body: some View {
+ imageView
+ .resizable()
+ .frame(width: size, height: size)
+ .cornerRadius(style.cornerRadiusFraction * size)
+ .task {
+ if let url {
+ image = await fetchAvatar(url)
+ }
+ }
+ // tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes
+ .id(url)
+
+ }
+
+ private var imageView: Image {
+ if let image {
+ return Image(uiImage: image)
+ } else {
+ return placeholder
+ }
+ }
+
+ private var placeholder: Image {
+ Image(systemName: style == .roundRect ? "person.crop.square" : "person.crop.circle")
+ }
+
+ public enum Style: Equatable {
+ case roundRect, circle
+
+ var cornerRadiusFraction: CGFloat {
+ switch self {
+ case .roundRect:
+ return 0.1
+ case .circle:
+ return 0.5
+ }
+ }
+ }
+}
diff --git a/Tusker/Views/GIFImageView.swift b/Packages/TuskerComponents/Sources/TuskerComponents/GIFImageView.swift
similarity index 58%
rename from Tusker/Views/GIFImageView.swift
rename to Packages/TuskerComponents/Sources/TuskerComponents/GIFImageView.swift
index 2b43f816..294e8492 100644
--- a/Tusker/Views/GIFImageView.swift
+++ b/Packages/TuskerComponents/Sources/TuskerComponents/GIFImageView.swift
@@ -1,6 +1,6 @@
//
// GIFImageView.swift
-// Tusker
+// TuskerComponents
//
// Created by Shadowfacts on 11/11/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
@@ -8,36 +8,36 @@
import UIKit
-class GIFImageView: UIImageView {
+open class GIFImageView: UIImageView {
- fileprivate(set) var gifController: GIFController? = nil
- var isAnimatingGIF: Bool { gifController?.state == .playing }
+ public fileprivate(set) var gifController: GIFController? = nil
+ public var isAnimatingGIF: Bool { gifController?.state == .playing }
/// Detaches the current GIF controller from this view.
/// If this view is the GIF controller's only one, it will stop itself.
- func detachGIFController() {
+ public func detachGIFController() {
gifController?.detach(from: self)
}
}
/// A `GIFController` controls the animation of one or more `GIFImageView`s.
-class GIFController {
+public class GIFController {
// GIFImageView strongly holds the controller so that when the last view detaches, the controller is freed
private var imageViews = WeakArray()
- private(set) var gifData: Data
+ public let gifData: Data
private(set) var state: State = .stopped
- private(set) var lastFrame: (image: UIImage, index: Int)? = nil
+ public private(set) var lastFrame: (image: UIImage, index: Int)? = nil
- init(gifData: Data) {
+ public init(gifData: Data) {
self.gifData = gifData
}
/// Attaches another view to this controller, letting it play back alongside the others.
/// Immediately brings it into sync with the others, setting the last frame if there was one.
- func attach(to view: GIFImageView) {
+ public func attach(to view: GIFImageView) {
imageViews.append(view)
view.gifController = self
@@ -49,13 +49,13 @@ class GIFController {
/// Detaches the given view from this controller.
/// If no views attached views remain, the last strong reference to this controller is nilled out
/// and image animation will stop at the next CGAnimateImageDataWithBlock callback.
- func detach(from view: GIFImageView) {
+ public func detach(from view: GIFImageView) {
// todo: does === work the way i want here
imageViews.removeAll(where: { $0 === view })
view.gifController = nil
}
- func startAnimating() {
+ public func startAnimating() {
guard state.shouldStop else { return }
state = .playing
@@ -74,7 +74,7 @@ class GIFController {
}
}
- func stopAnimating() {
+ public func stopAnimating() {
guard state == .playing else { return }
state = .stopping
@@ -89,3 +89,47 @@ class GIFController {
}
}
+
+private class WeakHolder {
+ weak var object: T?
+
+ init(_ object: T?) {
+ self.object = object
+ }
+}
+
+private struct WeakArray: MutableCollection, RangeReplaceableCollection {
+ private var array: [WeakHolder]
+
+ var startIndex: Int { array.startIndex }
+ var endIndex: Int { array.endIndex }
+
+ init() {
+ array = []
+ }
+
+ init(_ elements: [Element]) {
+ array = elements.map { WeakHolder($0) }
+ }
+
+ init(_ elements: [Element?]) {
+ array = elements.map { WeakHolder($0) }
+ }
+
+ subscript(position: Int) -> Element? {
+ get {
+ array[position].object
+ }
+ set(newValue) {
+ array[position] = WeakHolder(newValue)
+ }
+ }
+
+ func index(after i: Int) -> Int {
+ return array.index(after: i)
+ }
+
+ mutating func replaceSubrange(_ subrange: Range, with newElements: C) where C : Collection, Self.Element == C.Element {
+ array.replaceSubrange(subrange, with: newElements.map { WeakHolder($0) })
+ }
+}
diff --git a/Tusker/Views/MenuPicker.swift b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift
similarity index 73%
rename from Tusker/Views/MenuPicker.swift
rename to Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift
index 03dc3c44..21c0c8e9 100644
--- a/Tusker/Views/MenuPicker.swift
+++ b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift
@@ -1,6 +1,6 @@
//
// MenuPicker.swift
-// Tusker
+// TuskerComponents
//
// Created by Shadowfacts on 11/7/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
@@ -8,25 +8,31 @@
import SwiftUI
-struct MenuPicker: UIViewRepresentable {
- typealias UIViewType = UIButton
+public struct MenuPicker: UIViewRepresentable {
+ public typealias UIViewType = UIButton
@Binding var selection: Value
let options: [Option]
- var buttonStyle: ButtonStyle = .labelAndIcon
+ var buttonStyle: ButtonStyle
private var selectedOption: Option {
options.first(where: { $0.value == selection })!
}
- func makeUIView(context: Context) -> UIButton {
+ public init(selection: Binding, options: [Option], buttonStyle: ButtonStyle = .labelAndIcon) {
+ self._selection = selection
+ self.options = options
+ self.buttonStyle = buttonStyle
+ }
+
+ public func makeUIView(context: Context) -> UIButton {
let button = UIButton()
button.showsMenuAsPrimaryAction = true
button.setContentHuggingPriority(.required, for: .horizontal)
return button
}
- func updateUIView(_ button: UIButton, context: Context) {
+ public func updateUIView(_ button: UIButton, context: Context) {
var config = UIButton.Configuration.borderless()
if #available(iOS 16.0, *) {
config.indicator = .popup
@@ -49,14 +55,14 @@ struct MenuPicker: UIViewRepresentable {
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
}
- struct Option {
- let value: Value
- let title: String
- let subtitle: String?
- let image: UIImage?
- let accessibilityLabel: String?
+ public struct Option {
+ public let value: Value
+ public let title: String
+ public let subtitle: String?
+ public let image: UIImage?
+ public let accessibilityLabel: String?
- init(value: Value, title: String, subtitle: String? = nil, image: UIImage? = nil, accessibilityLabel: String? = nil) {
+ public init(value: Value, title: String, subtitle: String? = nil, image: UIImage? = nil, accessibilityLabel: String? = nil) {
self.value = value
self.title = title
self.subtitle = subtitle
@@ -65,7 +71,7 @@ struct MenuPicker: UIViewRepresentable {
}
}
- enum ButtonStyle {
+ public enum ButtonStyle {
case labelAndIcon, labelOnly, iconOnly
var hasLabel: Bool {
diff --git a/Packages/UserAccounts/.gitignore b/Packages/UserAccounts/.gitignore
new file mode 100644
index 00000000..3b298120
--- /dev/null
+++ b/Packages/UserAccounts/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
+DerivedData/
+.swiftpm/config/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/Packages/UserAccounts/Package.resolved b/Packages/UserAccounts/Package.resolved
new file mode 100644
index 00000000..a9ae5ab1
--- /dev/null
+++ b/Packages/UserAccounts/Package.resolved
@@ -0,0 +1,23 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-url",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/karwa/swift-url.git",
+ "state" : {
+ "branch" : "main",
+ "revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Packages/UserAccounts/Package.swift b/Packages/UserAccounts/Package.swift
new file mode 100644
index 00000000..cc7656a8
--- /dev/null
+++ b/Packages/UserAccounts/Package.swift
@@ -0,0 +1,31 @@
+// swift-tools-version: 5.7
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "UserAccounts",
+ platforms: [
+ .iOS(.v15),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, and make them visible to other packages.
+ .library(
+ name: "UserAccounts",
+ targets: ["UserAccounts"]),
+ ],
+ dependencies: [
+ // Dependencies declare other packages that this package depends on.
+ .package(path: "../Pachyderm"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package. A target can define a module or a test suite.
+ // Targets can depend on other targets in this package, and on products in packages this package depends on.
+ .target(
+ name: "UserAccounts",
+ dependencies: ["Pachyderm"]),
+ .testTarget(
+ name: "UserAccountsTests",
+ dependencies: ["UserAccounts"]),
+ ]
+)
diff --git a/Packages/UserAccounts/README.md b/Packages/UserAccounts/README.md
new file mode 100644
index 00000000..c2ecdca8
--- /dev/null
+++ b/Packages/UserAccounts/README.md
@@ -0,0 +1,3 @@
+# UserAccounts
+
+A description of this package.
diff --git a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift
new file mode 100644
index 00000000..2fac2f8a
--- /dev/null
+++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift
@@ -0,0 +1,80 @@
+//
+// UserAccountInfo.swift
+// UserAccounts
+//
+// Created by Shadowfacts on 3/5/23.
+//
+
+import Foundation
+import CryptoKit
+
+public struct UserAccountInfo: Equatable, Hashable {
+ public let id: String
+ public let instanceURL: URL
+ public let clientID: String
+ public let clientSecret: String
+ public private(set) var username: String!
+ public let accessToken: String
+
+ fileprivate static let tempAccountID = "temp"
+
+ static func id(instanceURL: URL, username: String?) -> String {
+ // We hash the instance host and username to form the account ID
+ // so that account IDs will match across devices, allowing for data syncing and handoff.
+ var hasher = SHA256()
+ hasher.update(data: instanceURL.host!.data(using: .utf8)!)
+ if let username {
+ hasher.update(data: username.data(using: .utf8)!)
+ }
+ return Data(hasher.finalize()).base64EncodedString()
+ }
+
+ /// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username
+ public init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
+ self.id = UserAccountInfo.tempAccountID
+ self.instanceURL = instanceURL
+ self.clientID = clientID
+ self.clientSecret = clientSecret
+ self.accessToken = accessToken
+ }
+
+ init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
+ self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
+ self.instanceURL = instanceURL
+ self.clientID = clientID
+ self.clientSecret = clientSecret
+ self.username = username
+ self.accessToken = accessToken
+ }
+
+ init?(userDefaultsDict dict: [String: String]) {
+ guard let id = dict["id"],
+ let instanceURL = dict["instanceURL"],
+ let url = URL(string: instanceURL),
+ let clientID = dict["clientID"],
+ let secret = dict["clientSecret"],
+ let accessToken = dict["accessToken"] else {
+ return nil
+ }
+ self.id = id
+ self.instanceURL = url
+ self.clientID = clientID
+ self.clientSecret = secret
+ self.username = dict["username"]
+ self.accessToken = accessToken
+ }
+
+ /// A filename-safe string for this account
+ public var persistenceKey: String {
+ // slashes are not allowed in the persistent store coordinator name
+ id.replacingOccurrences(of: "/", with: "_")
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+
+ public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool {
+ return lhs.id == rhs.id
+ }
+}
diff --git a/Tusker/LocalData.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift
similarity index 55%
rename from Tusker/LocalData.swift
rename to Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift
index 4da80d77..e69973ae 100644
--- a/Tusker/LocalData.swift
+++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift
@@ -1,18 +1,16 @@
//
-// LocalData.swift
-// Tusker
+// UserAccountsManager.swift
+// UserAccounts
//
-// Created by Shadowfacts on 8/18/18.
-// Copyright © 2018 Shadowfacts. All rights reserved.
+// Created by Shadowfacts on 3/5/23.
//
import Foundation
import Combine
-import CryptoKit
-class LocalData: ObservableObject {
+public class UserAccountsManager: ObservableObject {
- static let shared = LocalData()
+ public static let shared = UserAccountsManager()
let defaults: UserDefaults
@@ -38,7 +36,7 @@ class LocalData: ObservableObject {
}
private let accountsKey = "accounts"
- private(set) var accounts: [UserAccountInfo] {
+ public private(set) var accounts: [UserAccountInfo] {
get {
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
@@ -66,7 +64,7 @@ class LocalData: ObservableObject {
}
private let mostRecentAccountKey = "mostRecentAccount"
- private(set) var mostRecentAccountID: String? {
+ public private(set) var mostRecentAccountID: String? {
get {
return defaults.string(forKey: mostRecentAccountKey)
}
@@ -109,13 +107,13 @@ class LocalData: ObservableObject {
usesAccountIDHashes = true
}
- // MARK: - Account Management
+ // MARK: Account Management
- var onboardingComplete: Bool {
+ public var onboardingComplete: Bool {
return !accounts.isEmpty
}
- func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
+ public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
var accounts = self.accounts
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index)
@@ -126,15 +124,15 @@ class LocalData: ObservableObject {
return info
}
- func removeAccount(_ info: UserAccountInfo) {
+ public func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id })
}
- func getAccount(id: String) -> UserAccountInfo? {
+ public func getAccount(id: String) -> UserAccountInfo? {
return accounts.first(where: { $0.id == id })
}
- func getMostRecentAccount() -> UserAccountInfo? {
+ public func getMostRecentAccount() -> UserAccountInfo? {
guard onboardingComplete else { return nil }
let mostRecent: UserAccountInfo?
if let id = mostRecentAccountID {
@@ -145,86 +143,13 @@ class LocalData: ObservableObject {
return mostRecent ?? accounts.first!
}
- func setMostRecentAccount(_ account: UserAccountInfo?) {
+ public func setMostRecentAccount(_ account: UserAccountInfo?) {
mostRecentAccountID = account?.id
}
}
-extension LocalData {
- struct UserAccountInfo: Equatable, Hashable {
- let id: String
- let instanceURL: URL
- let clientID: String
- let clientSecret: String
- private(set) var username: String!
- let accessToken: String
-
- fileprivate static let tempAccountID = "temp"
-
- fileprivate static func id(instanceURL: URL, username: String?) -> String {
- // We hash the instance host and username to form the account ID
- // so that account IDs will match across devices, allowing for data syncing and handoff.
- var hasher = SHA256()
- hasher.update(data: instanceURL.host!.data(using: .utf8)!)
- if let username {
- hasher.update(data: username.data(using: .utf8)!)
- }
- return Data(hasher.finalize()).base64EncodedString()
- }
-
- /// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username
- init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
- self.id = UserAccountInfo.tempAccountID
- self.instanceURL = instanceURL
- self.clientID = clientID
- self.clientSecret = clientSecret
- self.accessToken = accessToken
- }
-
- fileprivate init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
- self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
- self.instanceURL = instanceURL
- self.clientID = clientID
- self.clientSecret = clientSecret
- self.username = username
- self.accessToken = accessToken
- }
-
- fileprivate init?(userDefaultsDict dict: [String: String]) {
- guard let id = dict["id"],
- let instanceURL = dict["instanceURL"],
- let url = URL(string: instanceURL),
- let clientID = dict["clientID"],
- let secret = dict["clientSecret"],
- let accessToken = dict["accessToken"] else {
- return nil
- }
- self.id = id
- self.instanceURL = url
- self.clientID = clientID
- self.clientSecret = secret
- self.username = dict["username"]
- self.accessToken = accessToken
- }
-
- /// A filename-safe string for this account
- var persistenceKey: String {
- // slashes are not allowed in the persistent store coordinator name
- id.replacingOccurrences(of: "/", with: "_")
- }
-
- func hash(into hasher: inout Hasher) {
- hasher.combine(id)
- }
-
- static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool {
- return lhs.id == rhs.id
- }
- }
-}
-
-extension Notification.Name {
+public extension Notification.Name {
static let userLoggedOut = Notification.Name("Tusker.userLoggedOut")
static let addAccount = Notification.Name("Tusker.addAccount")
static let activateAccount = Notification.Name("Tusker.activateAccount")
diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj
index d7f04697..22ddb86f 100644
--- a/Tusker.xcodeproj/project.pbxproj
+++ b/Tusker.xcodeproj/project.pbxproj
@@ -40,7 +40,6 @@
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; };
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; };
- D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; };
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
@@ -82,13 +81,7 @@
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
- D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; };
- D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; };
- D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; };
- D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622759F24F1677200B82A16 /* ComposeHostingController.swift */; };
- D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; };
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
- D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; };
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; };
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; };
@@ -105,7 +98,6 @@
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 */; };
- D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; };
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6285B5221EA708700FE4B39 /* StatusFormat.swift */; };
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; };
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; };
@@ -113,11 +105,11 @@
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2425217ABF63005076CC /* UserActivityType.swift */; };
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9984279CA23900C26176 /* URLSession+Development.swift */; };
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; };
- D62E9989279DB2D100C26176 /* InstanceFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9988279DB2D100C26176 /* InstanceFeatures.swift */; };
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; };
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
+ D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */ = {isa = PBXBuildFile; productRef = D635237029B78A7D009ED5E7 /* TuskerComponents */; };
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = D63CC701290EC0B8000E19DE /* Sentry */; };
@@ -142,7 +134,6 @@
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */; };
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */; };
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */; };
- D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
@@ -170,11 +161,9 @@
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 */; };
- D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEF1263A4BE10082A153 /* ComposePollView.swift */; };
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 */; };
- D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */; };
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; };
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */; };
@@ -182,12 +171,7 @@
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
- D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
- D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
- D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
- D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; };
- D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; };
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; };
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
@@ -252,7 +236,6 @@
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 */; };
- D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */; };
D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */; };
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
@@ -268,6 +251,7 @@
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */; };
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */; };
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */; };
+ D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D6B0026D29B5248800C70BE2 /* UserAccounts */; };
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A123BD2C0600A066FA /* AssetPickerViewController.swift */; };
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A323BD2C8100A066FA /* AssetCollectionsListViewController.swift */; };
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A523BD2D0C00A066FA /* AssetCollectionViewController.swift */; };
@@ -291,12 +275,11 @@
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 */; };
+ D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; };
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
- D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.swift */; };
- D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */; };
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
- D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
@@ -311,7 +294,6 @@
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */; };
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; };
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; };
- D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
@@ -321,7 +303,6 @@
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; };
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B59292D684600D528E1 /* AccountListViewController.swift */; };
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
- D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD6212518A200E1C4BB /* Assets.xcassets */; };
@@ -333,8 +314,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 */; };
- D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
- D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.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 */; };
@@ -349,7 +328,6 @@
D6E343B0265AAD6B00C4AA01 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */; };
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D6E343BA265AAD8C00C4AA01 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = D6E343B9265AAD8C00C4AA01 /* Action.js */; };
- D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; };
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; };
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; };
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; };
@@ -358,7 +336,6 @@
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */; };
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */; };
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */; };
- D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; };
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
@@ -371,8 +348,8 @@
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */; };
- D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A5582920676800F496A8 /* ComposeToolbar.swift */; };
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
+ D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = D6FA94E029B52898006AAC51 /* InstanceFeatures */; };
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
/* End PBXBuildFile section */
@@ -458,7 +435,6 @@
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = ""; };
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = ""; };
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = ""; };
- D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; };
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = ""; };
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; };
@@ -499,13 +475,7 @@
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; };
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = ""; };
- D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = ""; };
- D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = ""; };
- D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = ""; };
- D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = ""; };
- D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = ""; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = ""; };
- D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = ""; };
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = ""; };
D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; };
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = ""; };
@@ -522,7 +492,6 @@
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = ""; };
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = ""; };
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = ""; };
- D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = ""; };
D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = ""; };
D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = ""; };
D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = ""; };
@@ -530,7 +499,6 @@
D62D2425217ABF63005076CC /* UserActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityType.swift; sourceTree = ""; };
D62E9984279CA23900C26176 /* URLSession+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Development.swift"; sourceTree = ""; };
D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = ""; };
- D62E9988279DB2D100C26176 /* InstanceFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceFeatures.swift; sourceTree = ""; };
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = ""; };
D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = ""; };
D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = ""; };
@@ -559,7 +527,6 @@
D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = ""; };
D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationTableViewCell.swift; sourceTree = ""; };
D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FollowRequestNotificationTableViewCell.xib; sourceTree = ""; };
- D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = ""; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = ""; };
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = ""; };
@@ -589,11 +556,9 @@
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = ""; };
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = ""; };
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = ""; };
- D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = ""; };
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; };
D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = ""; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = ""; };
- D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Visibility+Helpers.swift"; sourceTree = ""; };
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = ""; };
D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = ""; };
D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Delegates.swift"; sourceTree = ""; };
@@ -601,12 +566,7 @@
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; };
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; };
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = ""; };
- D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = ""; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = ""; };
- D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; };
- D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; };
- D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = ""; };
- D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = ""; };
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = ""; };
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = ""; };
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; };
@@ -672,7 +632,6 @@
D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = ""; };
D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = ""; };
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = ""; };
- D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextViewCaretScrolling.swift; sourceTree = ""; };
D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = ""; };
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = ""; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = ""; };
@@ -688,6 +647,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedImageView.swift; sourceTree = ""; };
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = ""; };
D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = ""; };
+ D6B0026C29B5245400C70BE2 /* UserAccounts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = UserAccounts; path = Packages/UserAccounts; sourceTree = ""; };
D6B053A123BD2C0600A066FA /* AssetPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerViewController.swift; sourceTree = ""; };
D6B053A323BD2C8100A066FA /* AssetCollectionsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionsListViewController.swift; sourceTree = ""; };
D6B053A523BD2D0C00A066FA /* AssetCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewController.swift; sourceTree = ""; };
@@ -711,12 +671,12 @@
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = ""; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; };
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; };
+ D6BD395729B6441F005FFD2B /* ComposeUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ComposeUI; path = Packages/ComposeUI; sourceTree = ""; };
+ D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = ""; };
+ D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; };
- D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = ""; };
- D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertWithData.swift; sourceTree = ""; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; };
- D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; };
@@ -731,7 +691,6 @@
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = ""; };
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = ""; };
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; };
- D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = ""; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = ""; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = ""; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = ""; };
@@ -741,7 +700,6 @@
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = ""; };
D6D12B59292D684600D528E1 /* AccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewController.swift; sourceTree = ""; };
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = ""; };
- D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = ""; };
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = ""; };
D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
@@ -760,8 +718,6 @@
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; };
- D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = ""; };
- D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = ""; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = ""; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = ""; };
@@ -778,7 +734,6 @@
D6E343B1265AAD6B00C4AA01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
D6E343B5265AAD6B00C4AA01 /* OpenInTusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInTusker.entitlements; sourceTree = ""; };
D6E343B9265AAD8C00C4AA01 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = ""; };
- D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = ""; };
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = ""; };
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = ""; };
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = ""; };
@@ -788,7 +743,6 @@
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = ""; };
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = ""; };
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitNavigationController.swift; sourceTree = ""; };
- D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = ""; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; };
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = ""; };
@@ -801,8 +755,8 @@
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = ""; };
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = ""; };
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = ""; };
- D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = ""; };
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = ""; };
+ D6FA94DF29B52891006AAC51 /* InstanceFeatures */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = InstanceFeatures; path = Packages/InstanceFeatures; sourceTree = ""; };
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = ""; };
/* End PBXFileReference section */
@@ -811,8 +765,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */,
+ D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */,
+ D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */,
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
D659F35E2953A212002D944A /* TTTKit in Frameworks */,
+ D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
@@ -881,8 +839,6 @@
D6285B5221EA708700FE4B39 /* StatusFormat.swift */,
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */,
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
- D677284D24ECC01D00C732D3 /* Draft.swift */,
- D627FF75217E923E00CC0648 /* DraftsManager.swift */,
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
D65B4B532971F71D00DABDFB /* EditedReport.swift */,
D600891A29848289005B4D00 /* PinnedTimeline.swift */,
@@ -1132,25 +1088,8 @@
isa = PBXGroup;
children = (
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
- D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */,
- D622759F24F1677200B82A16 /* ComposeHostingController.swift */,
- D677284724ECBCB100C732D3 /* ComposeView.swift */,
- D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
- D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
- D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */,
- D62275A924F1E01C00B82A16 /* ComposeTextView.swift */,
- D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */,
- D662AEF1263A4BE10082A153 /* ComposePollView.swift */,
- D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */,
- D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */,
- D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */,
- D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
- D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
- D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
- D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
- D6F6A5582920676800F496A8 /* ComposeToolbar.swift */,
- D6BEA248291C6118002F4D01 /* DraftsView.swift */,
+ D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */,
);
path = Compose;
sourceTree = "";
@@ -1307,7 +1246,6 @@
D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */,
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */,
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */,
- D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */,
D6333B362137838300CE884A /* AttributedString+Helpers.swift */,
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */,
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */,
@@ -1448,7 +1386,6 @@
isa = PBXGroup;
children = (
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
- D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
@@ -1459,12 +1396,10 @@
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
- D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
- D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
@@ -1537,6 +1472,10 @@
D674A50727F910F300BA03AC /* Pachyderm */,
D6BEA243291A0C83002F4D01 /* Duckable */,
D68A76F22953915C001DA1B3 /* TTTKit */,
+ D6B0026C29B5245400C70BE2 /* UserAccounts */,
+ D6FA94DF29B52891006AAC51 /* InstanceFeatures */,
+ D6BD395C29B789D5005FFD2B /* TuskerComponents */,
+ D6BD395729B6441F005FFD2B /* ComposeUI */,
D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
@@ -1573,7 +1512,6 @@
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D61F75BC293D099600C0B37F /* Lazy.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
- D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
@@ -1609,7 +1547,6 @@
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
- D6114E1627F8BB210080E273 /* VersionTests.swift */,
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */,
@@ -1693,9 +1630,7 @@
D6F953F121251A2F00CF0F2B /* API */ = {
isa = PBXGroup;
children = (
- D62E9988279DB2D100C26176 /* InstanceFeatures.swift */,
D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
- D6E9CDA7281A427800BBC98E /* PostService.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
@@ -1741,6 +1676,10 @@
D63CC701290EC0B8000E19DE /* Sentry */,
D6BEA244291A0EDE002F4D01 /* Duckable */,
D659F35D2953A212002D944A /* TTTKit */,
+ D6B0026D29B5248800C70BE2 /* UserAccounts */,
+ D6FA94E029B52898006AAC51 /* InstanceFeatures */,
+ D635237029B78A7D009ED5E7 /* TuskerComponents */,
+ D6BD395829B64426005FFD2B /* ComposeUI */,
);
productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@@ -1980,11 +1919,11 @@
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
- D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
+ D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */,
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */,
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */,
@@ -1997,7 +1936,6 @@
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */,
- D62E9989279DB2D100C26176 /* InstanceFeatures.swift in Sources */,
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
@@ -2007,13 +1945,11 @@
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
- D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */,
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
- D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */,
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */,
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
@@ -2033,7 +1969,6 @@
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */,
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */,
- D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */,
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
@@ -2053,8 +1988,6 @@
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
- D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
- D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
@@ -2073,17 +2006,13 @@
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
- D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
- D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
- D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
- D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
@@ -2110,7 +2039,6 @@
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
- D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */,
D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
@@ -2129,7 +2057,6 @@
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
- D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
@@ -2141,25 +2068,19 @@
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D659F36229541065002D944A /* TTTView.swift in Sources */,
- D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
- D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
- D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
- D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */,
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */,
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */,
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
- D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */,
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */,
- D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
@@ -2182,7 +2103,6 @@
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
- D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
@@ -2207,7 +2127,6 @@
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */,
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */,
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
- D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
@@ -2224,7 +2143,6 @@
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
- D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
@@ -2238,9 +2156,7 @@
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
- D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
- D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
@@ -2262,11 +2178,8 @@
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
- D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
- D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
- D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
@@ -2298,7 +2211,6 @@
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */,
- D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
);
@@ -2943,6 +2855,10 @@
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
+ D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = TuskerComponents;
+ };
D63CC701290EC0B8000E19DE /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
@@ -2966,10 +2882,22 @@
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
+ D6B0026D29B5248800C70BE2 /* UserAccounts */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = UserAccounts;
+ };
+ D6BD395829B64426005FFD2B /* ComposeUI */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = ComposeUI;
+ };
D6BEA244291A0EDE002F4D01 /* Duckable */ = {
isa = XCSwiftPackageProductDependency;
productName = Duckable;
};
+ D6FA94E029B52898006AAC51 /* InstanceFeatures */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = InstanceFeatures;
+ };
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */
diff --git a/Tusker/API/LogoutService.swift b/Tusker/API/LogoutService.swift
index 42b76255..e5837ee8 100644
--- a/Tusker/API/LogoutService.swift
+++ b/Tusker/API/LogoutService.swift
@@ -7,13 +7,14 @@
//
import Foundation
+import UserAccounts
@MainActor
class LogoutService {
- let accountInfo: LocalData.UserAccountInfo
+ let accountInfo: UserAccountInfo
private let mastodonController: MastodonController
- init(accountInfo: LocalData.UserAccountInfo) {
+ init(accountInfo: UserAccountInfo) {
self.accountInfo = accountInfo
self.mastodonController = MastodonController.getForAccount(accountInfo)
}
@@ -23,7 +24,7 @@ class LogoutService {
try? await self.mastodonController.client.revokeAccessToken()
}
MastodonController.removeForAccount(accountInfo)
- LocalData.shared.removeAccount(accountInfo)
+ UserAccountsManager.shared.removeAccount(accountInfo)
let psc = mastodonController.persistentContainer.persistentStoreCoordinator
for store in psc.persistentStores {
guard let url = store.url else {
diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift
index 7d496323..294202ee 100644
--- a/Tusker/API/MastodonController.swift
+++ b/Tusker/API/MastodonController.swift
@@ -9,17 +9,21 @@
import Foundation
import Pachyderm
import Combine
+import UserAccounts
+import InstanceFeatures
+import Sentry
+import ComposeUI
private let oauthScopes = [Scope.read, .write, .follow]
class MastodonController: ObservableObject {
- static private(set) var all = [LocalData.UserAccountInfo: MastodonController]()
+ static private(set) var all = [UserAccountInfo: MastodonController]()
@available(*, message: "do something less dumb")
static var first: MastodonController { all.first!.value }
- static func getForAccount(_ account: LocalData.UserAccountInfo) -> MastodonController {
+ static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
if let controller = all[account] {
return controller
} else {
@@ -33,7 +37,7 @@ class MastodonController: ObservableObject {
}
}
- static func removeForAccount(_ account: LocalData.UserAccountInfo) {
+ static func removeForAccount(_ account: UserAccountInfo) {
all.removeValue(forKey: account)
}
@@ -45,15 +49,15 @@ class MastodonController: ObservableObject {
private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL
- var accountInfo: LocalData.UserAccountInfo?
+ var accountInfo: UserAccountInfo?
var accountPreferences: AccountPreferences!
let client: Client!
+ let instanceFeatures = InstanceFeatures()
@Published private(set) var account: Account!
@Published private(set) var instance: Instance!
@Published private(set) var nodeInfo: NodeInfo!
- @Published private(set) var instanceFeatures = InstanceFeatures()
@Published private(set) var lists: [List] = []
@Published private(set) var customEmojis: [Emoji]?
@Published private(set) var followedHashtags: [FollowedHashtag] = []
@@ -85,11 +89,12 @@ class MastodonController: ObservableObject {
}
.sink { [unowned self] (instance, nodeInfo) in
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
+ setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
- $instanceFeatures
- .filter { [unowned self] in $0.canFollowHashtags && self.followedHashtags.isEmpty }
+ instanceFeatures.featuresUpdated
+ .filter { [unowned self] _ in self.instanceFeatures.canFollowHashtags && self.followedHashtags.isEmpty }
.sink { [unowned self] _ in
Task {
await self.loadFollowedHashtags()
@@ -454,4 +459,69 @@ class MastodonController: ObservableObject {
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
}
+ func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
+ var acctsToMention = [String]()
+
+ var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
+ var localOnly = false
+ var contentWarning = ""
+
+ if let inReplyToID = inReplyToID,
+ let inReplyTo = persistentContainer.status(for: inReplyToID) {
+ acctsToMention.append(inReplyTo.account.acct)
+ acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
+ visibility = min(visibility, inReplyTo.visibility)
+ localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly
+
+ if !inReplyTo.spoilerText.isEmpty {
+ switch Preferences.shared.contentWarningCopyMode {
+ case .doNotCopy:
+ break
+ case .asIs:
+ contentWarning = inReplyTo.spoilerText
+ case .prependRe:
+ if inReplyTo.spoilerText.lowercased().starts(with: "re:") {
+ contentWarning = inReplyTo.spoilerText
+ } else {
+ contentWarning = "re: \(inReplyTo.spoilerText)"
+ }
+ }
+ }
+ }
+ if let mentioningAcct = mentioningAcct {
+ acctsToMention.append(mentioningAcct)
+ }
+ if let ownAccount = self.account {
+ acctsToMention.removeAll(where: { $0 == ownAccount.acct })
+ }
+ acctsToMention = acctsToMention.uniques()
+
+ let draft = Draft(
+ accountID: accountInfo!.id,
+ text: text ?? acctsToMention.map { "@\($0) " }.joined(),
+ contentWarning: contentWarning,
+ inReplyToID: inReplyToID,
+ visibility: visibility,
+ localOnly: localOnly
+ )
+ DraftsManager.shared.add(draft)
+ return draft
+ }
+
+}
+
+private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
+ let crumb = Breadcrumb(level: .info, category: "MastodonController")
+ crumb.data = [
+ "instance": [
+ "version": instance.version
+ ],
+ ]
+ if let nodeInfo {
+ crumb.data!["nodeInfo"] = [
+ "software": nodeInfo.software.name,
+ "version": nodeInfo.software.version,
+ ]
+ }
+ SentrySDK.addBreadcrumb(crumb)
}
diff --git a/Tusker/API/ReblogService.swift b/Tusker/API/ReblogService.swift
index ca331aed..3b928979 100644
--- a/Tusker/API/ReblogService.swift
+++ b/Tusker/API/ReblogService.swift
@@ -17,7 +17,7 @@ class ReblogService {
private let status: StatusMO
var hapticFeedback = true
- var visibility: Status.Visibility? = nil
+ var visibility: Visibility? = nil
var requireConfirmation = Preferences.shared.confirmBeforeReblog
init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) {
@@ -39,8 +39,8 @@ class ReblogService {
let image: UIImage?
let reblogVisibilityActions: [CustomAlertController.MenuAction]?
if mastodonController.instanceFeatures.reblogVisibility {
- image = UIImage(systemName: Status.Visibility.public.unfilledImageName)
- reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in
+ image = UIImage(systemName: Visibility.public.unfilledImageName)
+ reblogVisibilityActions = [Visibility.unlisted, .private].map { visibility in
CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) {
// deliberately retain a strong reference to self
Task {
diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift
index 5ffd44e7..aa64b5a8 100644
--- a/Tusker/AppDelegate.swift
+++ b/Tusker/AppDelegate.swift
@@ -10,6 +10,8 @@ import UIKit
import CoreData
import OSLog
import Sentry
+import UserAccounts
+import ComposeUI
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
@@ -32,7 +34,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
if let oldSavedData = SavedDataManager.load() {
do {
for account in oldSavedData.accountIDs {
- guard let account = LocalData.shared.getAccount(id: account) else {
+ guard let account = UserAccountsManager.shared.getAccount(id: account) else {
continue
}
let controller = MastodonController.getForAccount(account)
@@ -46,6 +48,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// no-op
}
}
+
+ 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) {
+ SentrySDK.capture(error: error)
+ }
+ }
+ }
return true
}
diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift
index 53f990d1..c8c0b388 100644
--- a/Tusker/CoreData/AccountPreferences.swift
+++ b/Tusker/CoreData/AccountPreferences.swift
@@ -9,11 +9,12 @@
import Foundation
import CoreData
import Pachyderm
+import UserAccounts
@objc(AccountPreferences)
public final class AccountPreferences: NSManagedObject {
- @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest {
+ @nonobjc class func fetchRequest(account: UserAccountInfo) -> NSFetchRequest {
let req = NSFetchRequest(entityName: "AccountPreferences")
req.predicate = NSPredicate(format: "accountID = %@", account.id)
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
@@ -27,7 +28,7 @@ public final class AccountPreferences: NSManagedObject {
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines)
var pinnedTimelines: [PinnedTimeline]
- static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
+ static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context)
prefs.accountID = account.id
prefs.createdAt = Date()
diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift
index 1c6b78d7..1859cdb2 100644
--- a/Tusker/CoreData/MastodonCachePersistentStore.swift
+++ b/Tusker/CoreData/MastodonCachePersistentStore.swift
@@ -13,12 +13,13 @@ import Combine
import OSLog
import Sentry
import CloudKit
+import UserAccounts
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
- private let accountInfo: LocalData.UserAccountInfo?
+ private let accountInfo: UserAccountInfo?
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
@@ -51,7 +52,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
let accountSubject = PassthroughSubject()
let relationshipSubject = PassthroughSubject()
- init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
+ init(for accountInfo: UserAccountInfo?, transient: Bool = false) {
self.accountInfo = accountInfo
let group = DispatchGroup()
@@ -320,7 +321,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
@discardableResult
private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO {
- if let relationshipMO = self.relationship(forAccount: relationship.id, in: context) {
+ if let relationshipMO = self.relationship(forAccount: relationship.accountID, in: context) {
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
return relationshipMO
} else {
@@ -335,7 +336,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
let relationshipMO = self.upsert(relationship: relationship, in: context)
self.save(context: context)
completion?(relationshipMO)
- self.relationshipSubject.send(relationship.id)
+ self.relationshipSubject.send(relationship.accountID)
}
}
diff --git a/Tusker/CoreData/RelationshipMO.swift b/Tusker/CoreData/RelationshipMO.swift
index 175287bc..260830fc 100644
--- a/Tusker/CoreData/RelationshipMO.swift
+++ b/Tusker/CoreData/RelationshipMO.swift
@@ -11,7 +11,7 @@ import CoreData
import Pachyderm
@objc(RelationshipMO)
-public final class RelationshipMO: NSManagedObject {
+public final class RelationshipMO: NSManagedObject, RelationshipProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest {
return NSFetchRequest(entityName: "Relationship")
@@ -29,6 +29,8 @@ public final class RelationshipMO: NSManagedObject {
@NSManaged public var showingReblogs: Bool
@NSManaged public var account: AccountMO?
+ public var followRequested: Bool { requested }
+
}
extension RelationshipMO {
@@ -43,10 +45,10 @@ extension RelationshipMO {
return
}
- self.accountID = relationship.id
+ self.accountID = relationship.accountID
self.blocking = relationship.blocking
self.domainBlocking = relationship.domainBlocking
- self.endorsed = relationship.endorsed ?? false
+ self.endorsed = relationship.endorsed
self.followedBy = relationship.followedBy
self.following = relationship.following
self.muting = relationship.muting
@@ -54,6 +56,6 @@ extension RelationshipMO {
self.requested = relationship.followRequested
self.showingReblogs = relationship.showingReblogs
- self.account = container.account(for: relationship.id, in: context)
+ self.account = container.account(for: relationship.accountID, in: context)
}
}
diff --git a/Tusker/CoreData/SavedHashtag.swift b/Tusker/CoreData/SavedHashtag.swift
index 265513b9..f206d456 100644
--- a/Tusker/CoreData/SavedHashtag.swift
+++ b/Tusker/CoreData/SavedHashtag.swift
@@ -10,6 +10,7 @@ import Foundation
import CoreData
import Pachyderm
import WebURLFoundationExtras
+import UserAccounts
@objc(SavedHashtag)
public final class SavedHashtag: NSManagedObject {
@@ -18,13 +19,13 @@ public final class SavedHashtag: NSManagedObject {
return NSFetchRequest(entityName: "SavedHashtag")
}
- @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest {
+ @nonobjc class func fetchRequest(account: UserAccountInfo) -> NSFetchRequest {
let req = NSFetchRequest(entityName: "SavedHashtag")
req.predicate = NSPredicate(format: "accountID = %@", account.id)
return req
}
- @nonobjc class func fetchRequest(name: String, account: LocalData.UserAccountInfo) -> NSFetchRequest {
+ @nonobjc class func fetchRequest(name: String, account: UserAccountInfo) -> NSFetchRequest {
let req = NSFetchRequest(entityName: "SavedHashtag")
req.predicate = NSPredicate(format: "name LIKE[cd] %@ AND accountID = %@", name, account.id)
return req
@@ -37,7 +38,7 @@ public final class SavedHashtag: NSManagedObject {
}
extension SavedHashtag {
- convenience init(hashtag: Hashtag, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
+ convenience init(hashtag: Hashtag, account: UserAccountInfo, context: NSManagedObjectContext) {
self.init(context: context)
self.accountID = account.id
self.name = hashtag.name
diff --git a/Tusker/CoreData/SavedInstance.swift b/Tusker/CoreData/SavedInstance.swift
index f2c612e8..d6e938b7 100644
--- a/Tusker/CoreData/SavedInstance.swift
+++ b/Tusker/CoreData/SavedInstance.swift
@@ -8,6 +8,7 @@
import Foundation
import CoreData
+import UserAccounts
@objc(SavedInstance)
public final class SavedInstance: NSManagedObject {
@@ -16,13 +17,13 @@ public final class SavedInstance: NSManagedObject {
return NSFetchRequest(entityName: "SavedInstance")
}
- @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest {
+ @nonobjc class func fetchRequest(account: UserAccountInfo) -> NSFetchRequest {
let req = NSFetchRequest(entityName: "SavedInstance")
req.predicate = NSPredicate(format: "accountID = %@", account.id)
return req
}
- @nonobjc class func fetchRequest(url: URL, account: LocalData.UserAccountInfo) -> NSFetchRequest {
+ @nonobjc class func fetchRequest(url: URL, account: UserAccountInfo) -> NSFetchRequest {
let req = NSFetchRequest(entityName: "SavedInstance")
req.predicate = NSPredicate(format: "url = %@ AND accountID = %@", url as NSURL, account.id)
return req
@@ -34,7 +35,7 @@ public final class SavedInstance: NSManagedObject {
}
extension SavedInstance {
- convenience init(url: URL, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
+ convenience init(url: URL, account: UserAccountInfo, context: NSManagedObjectContext) {
self.init(context: context)
self.accountID = account.id
self.url = url
diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift
index 6d910991..cdb14745 100644
--- a/Tusker/CoreData/StatusMO.swift
+++ b/Tusker/CoreData/StatusMO.swift
@@ -75,9 +75,9 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
public var pinned: Bool? { pinnedInternal }
public var bookmarked: Bool? { bookmarkedInternal }
- public var visibility: Pachyderm.Status.Visibility {
+ public var visibility: Pachyderm.Visibility {
get {
- Pachyderm.Status.Visibility(rawValue: visibilityString) ?? .public
+ Pachyderm.Visibility(rawValue: visibilityString) ?? .public
}
set {
visibilityString = newValue.rawValue
diff --git a/Tusker/CoreData/TimelinePosition.swift b/Tusker/CoreData/TimelinePosition.swift
index 444bae4a..99d164a6 100644
--- a/Tusker/CoreData/TimelinePosition.swift
+++ b/Tusker/CoreData/TimelinePosition.swift
@@ -9,11 +9,12 @@
import Foundation
import CoreData
import Pachyderm
+import UserAccounts
@objc(TimelinePosition)
public final class TimelinePosition: NSManagedObject {
- @nonobjc class func fetchRequest(timeline: Timeline, account: LocalData.UserAccountInfo) -> NSFetchRequest {
+ @nonobjc class func fetchRequest(timeline: Timeline, account: UserAccountInfo) -> NSFetchRequest {
let req = NSFetchRequest(entityName: "TimelinePosition")
req.predicate = NSPredicate(format: "accountID = %@ AND timelineKind = %@", account.id, toTimelineKind(timeline))
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
@@ -34,7 +35,7 @@ public final class TimelinePosition: NSManagedObject {
set { timelineKind = toTimelineKind(newValue) }
}
- convenience init(timeline: Timeline, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
+ convenience init(timeline: Timeline, account: UserAccountInfo, context: NSManagedObjectContext) {
self.init(context: context)
self.timeline = timeline
self.accountID = account.id
diff --git a/Tusker/Extensions/Array+Uniques.swift b/Tusker/Extensions/Array+Uniques.swift
index e2e77eba..b5490ad5 100644
--- a/Tusker/Extensions/Array+Uniques.swift
+++ b/Tusker/Extensions/Array+Uniques.swift
@@ -11,10 +11,10 @@ import Foundation
extension Array {
func uniques(by identify: (Element) -> ID) -> [Element] {
var uniques = Set>()
- for elem in self {
- uniques.insert(Hashed(element: elem, id: identify(elem)))
+ for (index, elem) in self.enumerated() {
+ uniques.insert(Hashed(element: elem, id: identify(elem), origIndex: index))
}
- return uniques.map(\.element)
+ return uniques.sorted(by: { $0.origIndex < $1.origIndex }).map(\.element)
}
}
@@ -27,6 +27,7 @@ extension Array where Element: Hashable {
fileprivate struct Hashed: Hashable {
let element: Element
let id: ID
+ let origIndex: Int
static func ==(lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
diff --git a/Tusker/Extensions/Date+TimeAgo.swift b/Tusker/Extensions/Date+TimeAgo.swift
index bce334e2..cf99ff2b 100644
--- a/Tusker/Extensions/Date+TimeAgo.swift
+++ b/Tusker/Extensions/Date+TimeAgo.swift
@@ -7,6 +7,7 @@
//
import Foundation
+import TuskerComponents
extension Date {
@@ -34,30 +35,7 @@ extension Date {
}
func timeAgoString() -> String {
- let (amount, component) = timeAgo()
-
- switch component {
- case .year:
- return "\(amount)y"
- case .month:
- return "\(amount)mo"
- case .weekOfYear:
- return "\(amount)w"
- case .day:
- return "\(amount)d"
- case .hour:
- return "\(amount)h"
- case .minute:
- return "\(amount)m"
- case .second:
- if amount >= 3 {
- return "\(amount)s"
- } else {
- return "Now"
- }
- default:
- fatalError("Unexpected component: \(component)")
- }
+ self.formatted(.abbreviatedTimeAgo)
}
}
diff --git a/Tusker/Models/CompositionAttachment.swift b/Tusker/Models/CompositionAttachment.swift
index 9f7d621f..2c51302e 100644
--- a/Tusker/Models/CompositionAttachment.swift
+++ b/Tusker/Models/CompositionAttachment.swift
@@ -70,9 +70,9 @@ extension CompositionAttachment: NSItemProviderWriting {
} catch {
completionHandler(nil, error)
}
+ } else {
+ completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier)
}
-
- completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier)
return nil
}
diff --git a/Tusker/Models/CompositionAttachmentData.swift b/Tusker/Models/CompositionAttachmentData.swift
index 10fec3d8..112b974a 100644
--- a/Tusker/Models/CompositionAttachmentData.swift
+++ b/Tusker/Models/CompositionAttachmentData.swift
@@ -10,6 +10,7 @@ import UIKit
import Photos
import UniformTypeIdentifiers
import PencilKit
+import InstanceFeatures
enum CompositionAttachmentData {
case asset(PHAsset)
diff --git a/Tusker/Models/DraftsManager.swift b/Tusker/Models/DraftsManager.swift
deleted file mode 100644
index 8f7d9c1e..00000000
--- a/Tusker/Models/DraftsManager.swift
+++ /dev/null
@@ -1,77 +0,0 @@
-//
-// DraftsManager.swift
-// Tusker
-//
-// Created by Shadowfacts on 10/22/18.
-// Copyright © 2018 Shadowfacts. All rights reserved.
-//
-
-import Foundation
-
-class DraftsManager: Codable, ObservableObject {
-
- private(set) static var shared: DraftsManager = load()
-
- private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
- private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
-
- static func save() {
- DispatchQueue.global(qos: .utility).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()
- }
-
- private init() {}
-
- required init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
-
- if let dict = try? container.decode([UUID: Draft].self, forKey: .drafts) {
- self.drafts = dict
- } else if let array = try? container.decode([Draft].self, forKey: .drafts) {
- self.drafts = array.reduce(into: [:], { partialResult, draft in
- partialResult[draft.id] = draft
- })
- } else {
- throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts")
- }
- }
-
- 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 })
- }
-
- func add(_ draft: Draft) {
- drafts[draft.id] = draft
- }
-
- func remove(_ draft: Draft) {
- drafts.removeValue(forKey: draft.id)
- }
-
- func getBy(id: UUID) -> Draft? {
- return drafts[id]
- }
-
- enum CodingKeys: String, CodingKey {
- case drafts
- }
-
-}
diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift
index 1cf03c4e..b5e8654e 100644
--- a/Tusker/Preferences/Preferences.swift
+++ b/Tusker/Preferences/Preferences.swift
@@ -49,7 +49,7 @@ class Preferences: Codable, ObservableObject {
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
- self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
+ self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility)
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
@@ -158,7 +158,7 @@ class Preferences: Codable, ObservableObject {
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
// MARK: Composing
- @Published var defaultPostVisibility = Status.Visibility.public
+ @Published var defaultPostVisibility = Visibility.public
@Published var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published var automaticallySaveDrafts = true
@Published var requireAttachmentDescriptions = false
@@ -266,11 +266,11 @@ class Preferences: Codable, ObservableObject {
extension Preferences {
enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost
- case visibility(Status.Visibility)
+ case visibility(Visibility)
- static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Status.Visibility.allCases.map { .visibility($0) }
+ static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
- var resolved: Status.Visibility {
+ var resolved: Visibility {
switch self {
case .sameAsPost:
return Preferences.shared.defaultPostVisibility
diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift
index 211f7c63..7480865f 100644
--- a/Tusker/Scenes/AuxiliarySceneDelegate.swift
+++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift
@@ -8,6 +8,7 @@
import UIKit
import Pachyderm
+import UserAccounts
class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
@@ -31,11 +32,11 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
}
launchActivity = activity
- let account: LocalData.UserAccountInfo
+ let account: UserAccountInfo
if let activityAccount = UserActivityManager.getAccount(from: activity) {
account = activityAccount
- } else if let mostRecent = LocalData.shared.getMostRecentAccount() {
+ } else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {
account = mostRecent
} else {
// without an account, we can't do anything so we just destroy the scene
diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift
index 61f2de1c..339c6253 100644
--- a/Tusker/Scenes/ComposeSceneDelegate.swift
+++ b/Tusker/Scenes/ComposeSceneDelegate.swift
@@ -8,6 +8,8 @@
import UIKit
import Combine
+import UserAccounts
+import ComposeUI
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
@@ -22,12 +24,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
return
}
- guard LocalData.shared.onboardingComplete else {
+ guard UserAccountsManager.shared.onboardingComplete else {
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
return
}
- let account: LocalData.UserAccountInfo
+ let account: UserAccountInfo
let controller: MastodonController
let draft: Draft?
@@ -36,7 +38,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
account = activityAccount
} else {
// todo: this potentially changes the account for the draft, should show the same warning to user as in the drafts selection screen
- account = LocalData.shared.getMostRecentAccount()!
+ account = UserAccountsManager.shared.getMostRecentAccount()!
}
controller = MastodonController.getForAccount(account)
@@ -49,7 +51,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
draft = nil
}
} else {
- account = LocalData.shared.getMostRecentAccount()!
+ account = UserAccountsManager.shared.getMostRecentAccount()!
controller = MastodonController.getForAccount(account)
draft = nil
}
@@ -61,15 +63,15 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
composeVC.delegate = self
let nav = EnhancedNavigationViewController(rootViewController: composeVC)
- updateTitle(draft: composeVC.draft)
- composeVC.uiState.$draft
- .sink { [unowned self] in self.updateTitle(draft: $0) }
- .store(in: &cancellables)
-
window = UIWindow(windowScene: windowScene)
window!.rootViewController = nav
window!.makeKeyAndVisible()
+ updateTitle(draft: composeVC.controller.draft)
+ composeVC.controller.$draft
+ .sink { [unowned self] in self.updateTitle(draft: $0) }
+ .store(in: &cancellables)
+
NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil)
themePrefChanged()
}
@@ -80,7 +82,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
if let window = window,
let nav = window.rootViewController as? UINavigationController,
let compose = nav.topViewController as? ComposeHostingController {
- scene.userActivity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
+ scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
}
}
@@ -108,7 +110,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
}
extension ComposeSceneDelegate: ComposeHostingControllerDelegate {
- func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool {
+ func dismissCompose(mode: DismissMode) -> Bool {
let animation: UIWindowScene.DismissalAnimation
switch mode {
case .cancel:
diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift
index 7ca55ae0..61739176 100644
--- a/Tusker/Scenes/MainSceneDelegate.swift
+++ b/Tusker/Scenes/MainSceneDelegate.swift
@@ -11,6 +11,8 @@ import Pachyderm
import MessageUI
import CoreData
import Duckable
+import UserAccounts
+import ComposeUI
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
@@ -161,13 +163,13 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
let session = session ?? window!.windowScene!.session
- if LocalData.shared.onboardingComplete {
- let account: LocalData.UserAccountInfo
+ if UserAccountsManager.shared.onboardingComplete {
+ let account: UserAccountInfo
if let activity = launchActivity,
let activityAccount = UserActivityManager.getAccount(from: activity) {
account = activityAccount
} else {
- account = LocalData.shared.getMostRecentAccount()!
+ account = UserAccountsManager.shared.getMostRecentAccount()!
}
if session.mastodonController == nil {
@@ -194,9 +196,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
}
}
- func activateAccount(_ account: LocalData.UserAccountInfo, animated: Bool) {
- let oldMostRecentAccount = LocalData.shared.mostRecentAccountID
- LocalData.shared.setMostRecentAccount(account)
+ func activateAccount(_ account: UserAccountInfo, animated: Bool) {
+ let oldMostRecentAccount = UserAccountsManager.shared.mostRecentAccountID
+ UserAccountsManager.shared.setMostRecentAccount(account)
window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account)
// iPadOS shows the title below the App Name
@@ -212,8 +214,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
let direction: AccountSwitchingContainerViewController.AnimationDirection
if animated,
- let oldIndex = LocalData.shared.accounts.firstIndex(where: { $0.id == oldMostRecentAccount }),
- let newIndex = LocalData.shared.accounts.firstIndex(of: account) {
+ let oldIndex = UserAccountsManager.shared.accounts.firstIndex(where: { $0.id == oldMostRecentAccount }),
+ let newIndex = UserAccountsManager.shared.accounts.firstIndex(of: account) {
direction = newIndex > oldIndex ? .upwards : .downwards
} else {
direction = .none
@@ -229,8 +231,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
return
}
LogoutService(accountInfo: account).run()
- if LocalData.shared.onboardingComplete {
- activateAccount(LocalData.shared.accounts.first!, animated: false)
+ if UserAccountsManager.shared.onboardingComplete {
+ activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
} else {
window!.rootViewController = createOnboardingUI()
}
@@ -269,7 +271,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
}
extension MainSceneDelegate: OnboardingViewControllerDelegate {
- func didFinishOnboarding(account: LocalData.UserAccountInfo) {
+ func didFinishOnboarding(account: UserAccountInfo) {
activateAccount(account, animated: false)
}
}
diff --git a/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift b/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift
index bf0f7cc2..92e5f504 100644
--- a/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift
+++ b/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift
@@ -8,6 +8,7 @@
import UIKit
import Pachyderm
+import TuskerComponents
class AttachmentPreviewViewController: UIViewController {
diff --git a/Tusker/Screens/Compose/ComposeAssetPicker.swift b/Tusker/Screens/Compose/ComposeAssetPicker.swift
deleted file mode 100644
index 12b3cb50..00000000
--- a/Tusker/Screens/Compose/ComposeAssetPicker.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-//
-// ComposeAssetPicker.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/19/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-
-struct ComposeAssetPicker: UIViewControllerRepresentable {
- typealias UIViewControllerType = AssetPickerViewController
-
- @ObservedObject var draft: Draft
- let delegate: AssetPickerViewControllerDelegate?
-
- @EnvironmentObject var mastodonController: MastodonController
-
- func makeUIViewController(context: Context) -> AssetPickerViewController {
- let vc = AssetPickerViewController()
- vc.assetPickerDelegate = delegate
- vc.preferredContentSize = CGSize(width: 400, height: 600)
- return vc
- }
-
- func updateUIViewController(_ uiViewController: AssetPickerViewController, context: Context) {
- }
-
-}
diff --git a/Tusker/Screens/Compose/ComposeAttachmentRow.swift b/Tusker/Screens/Compose/ComposeAttachmentRow.swift
deleted file mode 100644
index 29eb78b1..00000000
--- a/Tusker/Screens/Compose/ComposeAttachmentRow.swift
+++ /dev/null
@@ -1,164 +0,0 @@
-//
-// ComposeAttachmentRow.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/19/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-import Photos
-import AVFoundation
-import Vision
-
-struct ComposeAttachmentRow: View {
- @ObservedObject var draft: Draft
- @ObservedObject var attachment: CompositionAttachment
-
- @EnvironmentObject var mastodonController: MastodonController
- @EnvironmentObject var uiState: ComposeUIState
- @State private var mode: Mode = .allowEntry
- @State private var isShowingTextRecognitionFailedAlert = false
- @State private var textRecognitionErrorMessage: String? = nil
-
- var body: some View {
- HStack(alignment: .center, spacing: 4) {
- ComposeAttachmentImage(attachment: attachment, fullSize: false)
- .frame(width: 80, height: 80)
- .cornerRadius(8)
- .contextMenu {
- if case .drawing(_) = attachment.data {
- Button(action: self.editDrawing) {
- Label("Edit Drawing", systemImage: "hand.draw")
- }
- } else if attachment.data.type == .image {
- Button(action: self.recognizeText) {
- Label("Recognize Text", systemImage: "doc.text.viewfinder")
- }
- }
-
- Button(role: .destructive, action: self.removeAttachment) {
- Label("Delete", systemImage: "trash")
- }
- } previewIfAvailable: {
- ComposeAttachmentImage(attachment: attachment, fullSize: true)
- }
-
- switch mode {
- case .allowEntry:
- ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
- .backgroundColor(.clear)
-
- case .recognizingText:
- ProgressView()
- }
-
-
- // todo: find a way to make this button not activated when the list row is selected, see FB8595628
-// Button(action: self.removeAttachment) {
-// Image(systemName: "xmark.circle.fill")
-// .foregroundColor(.blue)
-// }
- }
- .onReceive(attachment.$attachmentDescription) { (newDesc) in
- if newDesc.isEmpty {
- uiState.attachmentsMissingDescriptions.insert(attachment.id)
- } else {
- uiState.attachmentsMissingDescriptions.remove(attachment.id)
- }
- }
- .alert(isPresented: $isShowingTextRecognitionFailedAlert) {
- Alert(
- title: Text("Text Recognition Failed"),
- message: Text(self.textRecognitionErrorMessage ?? ""),
- dismissButton: .default(Text("OK"))
- )
- }
- }
-
- private func removeAttachment() {
- withAnimation {
- draft.attachments.removeAll { $0.id == attachment.id }
- }
- }
-
- private func editDrawing() {
- uiState.composeDrawingMode = .edit(id: attachment.id)
- uiState.delegate?.presentComposeDrawing()
- }
-
- private func recognizeText() {
- mode = .recognizingText
-
- DispatchQueue.global(qos: .userInitiated).async {
- self.attachment.data.getData(features: mastodonController.instanceFeatures, skipAllConversion: true) { (result) in
- let data: Data
- do {
- try data = result.get().0
- } catch {
- DispatchQueue.main.async {
- self.mode = .allowEntry
- self.isShowingTextRecognitionFailedAlert = true
- self.textRecognitionErrorMessage = error.localizedDescription
- }
- return
- }
- let handler = VNImageRequestHandler(data: data, options: [:])
- let request = VNRecognizeTextRequest { (request, error) in
- DispatchQueue.main.async {
- if let results = request.results as? [VNRecognizedTextObservation] {
- var text = ""
- for observation in results {
- let result = observation.topCandidates(1).first!
- text.append(result.string)
- text.append("\n")
- }
- self.attachment.attachmentDescription = text
- }
-
- self.mode = .allowEntry
- }
- }
- request.recognitionLevel = .accurate
- request.usesLanguageCorrection = true
- DispatchQueue.global(qos: .userInitiated).async {
- do {
- try handler.perform([request])
- } catch {
- // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
- guard (error as NSError).code != 1 else { return }
- DispatchQueue.main.async {
- self.mode = .allowEntry
- self.isShowingTextRecognitionFailedAlert = true
- self.textRecognitionErrorMessage = error.localizedDescription
- }
- }
- }
- }
- }
- }
-}
-
-extension ComposeAttachmentRow {
- enum Mode {
- case allowEntry, recognizingText
- }
-}
-
-private extension View {
- @available(iOS, obsoleted: 16.0)
- @ViewBuilder
- func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
- if #available(iOS 16.0, *) {
- self.contextMenu(menuItems: menuItems, preview: preview)
- } else {
- self.contextMenu(menuItems: menuItems)
- }
- }
-}
-
-//struct ComposeAttachmentRow_Previews: PreviewProvider {
-// static var previews: some View {
-// ComposeAttachmentRow()
-// }
-//}
diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift
deleted file mode 100644
index 743caa0e..00000000
--- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift
+++ /dev/null
@@ -1,210 +0,0 @@
-//
-// ComposeAttachmentsList.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/19/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-
-struct ComposeAttachmentsList: View {
- private let cellHeight: CGFloat = 80
- private let cellPadding: CGFloat = 12
-
- @ObservedObject var draft: Draft
-
- @EnvironmentObject var mastodonController: MastodonController
- @EnvironmentObject var uiState: ComposeUIState
- @State var isShowingAssetPickerPopover = false
- @State var isShowingCreateDrawing = false
-
- @Environment(\.colorScheme) var colorScheme: ColorScheme
- @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
-
- var body: some View {
- Group {
- ForEach(draft.attachments) { (attachment) in
- ComposeAttachmentRow(
- draft: draft,
- attachment: attachment
- )
- .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
- .onDrag { NSItemProvider(object: attachment) }
- }
- .onMove(perform: self.moveAttachments)
- .onDelete(perform: self.deleteAttachments)
- .conditionally(canAddAttachment) {
- $0.onInsert(of: CompositionAttachment.readableTypeIdentifiersForItemProvider, perform: self.insertAttachments)
- }
-
- Button(action: self.addAttachment) {
- Label("Add photo or video", systemImage: addButtonImageName)
- }
- .disabled(!canAddAttachment)
- .foregroundColor(.accentColor)
- .frame(height: cellHeight / 2)
- .sheetOrPopover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover)
- .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
-
- Button(action: self.createDrawing) {
- Label("Draw something", systemImage: "hand.draw")
- }
- .disabled(!canAddAttachment)
- .foregroundColor(.accentColor)
- .frame(height: cellHeight / 2)
- .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
-
- Button(action: self.togglePoll) {
- Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
- }
- .disabled(!canAddPoll)
- .foregroundColor(.accentColor)
- .frame(height: cellHeight / 2)
- .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
- }
- .onAppear(perform: self.didAppear)
- }
-
- private var addButtonImageName: String {
- switch colorScheme {
- case .dark:
- return "photo.fill"
- case .light:
- return "photo"
- @unknown default:
- return "photo"
- }
- }
-
- private var canAddAttachment: Bool {
- if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
- return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil
- } else {
- return true
- }
- }
-
- private var canAddPoll: Bool {
- if mastodonController.instanceFeatures.pollsAndAttachments {
- return true
- } else {
- return draft.attachments.isEmpty
- }
- }
-
- private func didAppear() {
- if #available(iOS 16.0, *) {
- // these appearance proxy hacks are no longer necessary
- } else {
- let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
- // enable drag and drop to reorder on iPhone
- proxy.dragInteractionEnabled = true
- proxy.isScrollEnabled = false
- }
- }
-
- private func assetPickerPopover() -> some View {
- ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
- .onDisappear {
- // on iPadOS 16, this is necessary to dismiss the popover when collapsing from regular -> compact size class
- // otherwise, the popover isn't visible but it's still "presented", so the sheet can't be shown
- self.isShowingAssetPickerPopover = false
- }
- // on iPadOS 16, this is necessary to show the dark color in the popover arrow
- .background(Color(.appBackground))
- .environment(\.colorScheme, .dark)
- .edgesIgnoringSafeArea(.bottom)
- .withSheetDetentsIfAvailable()
- }
-
- private func addAttachment() {
- if #available(iOS 16.0, *) {
- isShowingAssetPickerPopover = true
- } else if horizontalSizeClass == .regular {
- isShowingAssetPickerPopover = true
- } else {
- uiState.delegate?.presentAssetPickerSheet()
- }
- }
-
- private func moveAttachments(from source: IndexSet, to destination: Int) {
- draft.attachments.move(fromOffsets: source, toOffset: destination)
- }
-
- private func deleteAttachments(at indices: IndexSet) {
- draft.attachments.remove(atOffsets: indices)
- }
-
- private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
- for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) {
- guard canAddAttachment else { break }
-
- provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
- guard let attachment = object as? CompositionAttachment else { return }
- DispatchQueue.main.async {
- self.draft.attachments.insert(attachment, at: offset)
- }
- }
- }
- }
-
- private func createDrawing() {
- uiState.composeDrawingMode = .createNew
- uiState.delegate?.presentComposeDrawing()
- }
-
- private func togglePoll() {
- UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
-
- withAnimation {
- draft.poll = draft.poll == nil ? Draft.Poll() : nil
- }
- }
-}
-
-fileprivate extension View {
- @available(iOS, obsoleted: 16.0)
- @ViewBuilder
- func sheetOrPopover(isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View {
- if #available(iOS 16.0, *) {
- self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
- } else {
- self.popover(isPresented: isPresented, content: content)
- }
- }
-
- @available(iOS, obsoleted: 16.0)
- @ViewBuilder
- func withSheetDetentsIfAvailable() -> some View {
- if #available(iOS 16.0, *) {
- self
- .presentationDetents([.medium, .large])
- .presentationDragIndicator(.visible)
- } else {
- self
- }
- }
-}
-
-@available(iOS 16.0, *)
-struct SheetOrPopover: ViewModifier {
- @Binding var isPresented: Bool
- @ViewBuilder let view: () -> V
-
- @Environment(\.horizontalSizeClass) var sizeClass
-
- func body(content: Content) -> some View {
- if sizeClass == .compact {
- content.sheet(isPresented: $isPresented, content: view)
- } else {
- content.popover(isPresented: $isPresented, content: view)
- }
- }
-}
-
-//struct ComposeAttachmentsList_Previews: PreviewProvider {
-// static var previews: some View {
-// ComposeAttachmentsList()
-// }
-//}
diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift
deleted file mode 100644
index 94a5a902..00000000
--- a/Tusker/Screens/Compose/ComposeAutocompleteView.swift
+++ /dev/null
@@ -1,424 +0,0 @@
-//
-// ComposeAutocompleteView.swift
-// Tusker
-//
-// Created by Shadowfacts on 10/10/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-import CoreData
-import Pachyderm
-
-struct ComposeAutocompleteView: View {
- let autocompleteState: ComposeUIState.AutocompleteState
-
- @Environment(\.colorScheme) var colorScheme: ColorScheme
-
- private var backgroundColor: Color {
- Color(white: colorScheme == .light ? 0.98 : 0.15)
- }
-
- private var borderColor: Color {
- Color(white: colorScheme == .light ? 0.85 : 0.25)
- }
-
- var body: some View {
- suggestionsView
- .background(backgroundColor)
- .overlay(borderColor.frame(height: 0.5), alignment: .top)
- }
-
- @ViewBuilder
- private var suggestionsView: some View {
- switch autocompleteState {
- case .mention(_):
- ComposeAutocompleteMentionsView()
- case .emoji(_):
- ComposeAutocompleteEmojisView()
- case .hashtag(_):
- ComposeAutocompleteHashtagsView()
- }
- }
-}
-
-struct ComposeAutocompleteMentionsView: View {
- @EnvironmentObject private var mastodonController: MastodonController
- @EnvironmentObject private var uiState: ComposeUIState
- @ObservedObject private var preferences = Preferences.shared
-
- // can't use AccountProtocol because of associated type requirements
- @State private var accounts: [AnyAccount] = []
-
- @State private var searchRequest: URLSessionTask?
-
- var body: some View {
- ScrollView(.horizontal) {
- // can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang
- HStack(spacing: 8) {
- ForEach(accounts, id: \.value.id) { (account) in
- Button {
- uiState.currentInput?.autocomplete(with: "@\(account.value.acct)")
- } label: {
- HStack(spacing: 4) {
- ComposeAvatarImageView(url: account.value.avatar)
- .frame(width: 30, height: 30)
- .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
-
- VStack(alignment: .leading) {
- AccountDisplayNameLabel(account: account.value, textStyle: .subheadline, emojiSize: 14)
- .foregroundColor(Color(UIColor.label))
-
- Text(verbatim: "@\(account.value.acct)")
- .font(.caption)
- .foregroundColor(Color(UIColor.label))
- }
- }
- }
- .frame(height: 30)
- .padding(.vertical, 8)
- }
- .animation(.linear(duration: 0.1), value: accounts)
-
- Spacer()
- }
- .padding(.horizontal, 8)
- }
- .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
- .onDisappear {
- searchRequest?.cancel()
- }
- }
-
- private func queryChanged(_ state: ComposeUIState.AutocompleteState?) {
- guard case let .mention(query) = state,
- !query.isEmpty else {
- accounts = []
- return
- }
-
- let localSearchWorkItem = DispatchWorkItem {
- // todo: there's got to be something more efficient than this :/
- let wildcardedQuery = query.map { "*\($0)" }.joined() + "*"
- let request: NSFetchRequest = AccountMO.fetchRequest()
- request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery)
-
- if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) {
- loadAccounts(results.map { .init(value: $0) }, query: query)
- }
- }
-
- // we only want to search locally if the search API call takes more than .25sec or it fails
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: localSearchWorkItem)
-
- if let oldRequest = searchRequest {
- oldRequest.cancel()
- }
-
- let apiRequest = Client.searchForAccount(query: query)
- searchRequest = mastodonController.run(apiRequest) { (response) in
- guard case let .success(accounts, _) = response else { return }
-
- localSearchWorkItem.cancel()
-
- // dispatch back to the main thread because loadAccounts uses CoreData
- DispatchQueue.main.async {
- // if the query has changed, don't bother loading the now-outdated results
- if case .mention(query) = uiState.autocompleteState {
- self.loadAccounts(accounts.map { .init(value: $0) }, query: query)
- }
- }
- }
- }
-
- private func loadAccounts(_ accounts: [AnyAccount], query: String) {
- // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
- let ignoreDomain = !query.contains("@")
-
- self.accounts =
- accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
- let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
- let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
- return res
- }
- .filter(\.1.matched)
- .map { (account, res) -> (AnyAccount, Int) in
- // give higher weight to accounts that the user follows or is followed by
- var score = res.score
- if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.value.id) {
- if relationship.following {
- score += 3
- }
- if relationship.followedBy {
- score += 2
- }
- }
- return (account, score)
- }
- .sorted { $0.1 > $1.1 }
- .map(\.0)
- }
-
- private struct AnyAccount: Equatable {
- let value: any AccountProtocol
-
- static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
- return lhs.value.id == rhs.value.id
- }
- }
-}
-
-struct ComposeAutocompleteEmojisView: View {
- @EnvironmentObject private var mastodonController: MastodonController
- @EnvironmentObject private var uiState: ComposeUIState
-
- @State var expanded = false
- @State private var emojis: [Emoji] = []
- @ScaledMetric private var emojiSize = 30
-
- private var emojisBySection: [String: [Emoji]] {
- var values: [String: [Emoji]] = [:]
- for emoji in emojis {
- let key = emoji.category ?? ""
- if !values.keys.contains(key) {
- values[key] = [emoji]
- } else {
- values[key]!.append(emoji)
- }
- }
- return values
- }
-
- var body: some View {
- // When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
- HStack(alignment: expanded ? .top : .center, spacing: 0) {
- if case let .emoji(query) = uiState.autocompleteState {
- emojiList(query: query)
- .transition(.move(edge: .bottom))
- .onReceive(uiState.$autocompleteState, perform: queryChanged)
- .onAppear {
- if uiState.shouldEmojiAutocompletionBeginExpanded {
- expanded = true
- uiState.shouldEmojiAutocompletionBeginExpanded = false
- }
- }
- } else {
- // when the autocomplete view is animating out, the autocomplete state is nil
- // add a spacer so the expand button remains on the right
- Spacer()
- }
-
- toggleExpandedButton
- .padding(.trailing, 8)
- .padding(.top, expanded ? 8 : 0)
- }
- }
-
- @ViewBuilder
- private func emojiList(query: String) -> some View {
- if expanded {
- verticalGrid
- .frame(height: 150)
- } else {
- horizontalScrollView
- }
- }
-
- private var verticalGrid: some View {
- ScrollView {
- LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
- ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
- Section {
- ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
- Button {
- uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
- } label: {
- CustomEmojiImageView(emoji: emoji)
- .frame(height: emojiSize)
- }
- .accessibilityLabel(emoji.shortcode)
- }
- } header: {
- if !section.isEmpty {
- VStack(alignment: .leading, spacing: 2) {
- Text(section)
- .font(.caption)
-
- Rectangle()
- .foregroundColor(Color(.separator))
- .frame(height: 0.5)
- }
- .padding(.top, 4)
- }
- }
- }
- }
- .padding(.all, 8)
- }
- .frame(maxWidth: .infinity)
- }
-
- private var horizontalScrollView: some View {
- ScrollView(.horizontal) {
- HStack(spacing: 8) {
- ForEach(emojis, id: \.shortcode) { (emoji) in
- Button {
- uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
- } label: {
- HStack(spacing: 4) {
- CustomEmojiImageView(emoji: emoji)
- .frame(height: emojiSize)
- Text(verbatim: ":\(emoji.shortcode):")
- .foregroundColor(Color(UIColor.label))
- }
- }
- .accessibilityLabel(emoji.shortcode)
- .frame(height: emojiSize)
- }
- .animation(.linear(duration: 0.2), value: emojis)
-
- Spacer(minLength: emojiSize)
- }
- .padding(.horizontal, 8)
- .frame(height: emojiSize + 16)
- }
- }
-
- private var toggleExpandedButton: some View {
- Button {
- withAnimation {
- expanded.toggle()
- }
- } label: {
- Image(systemName: "chevron.down")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .rotationEffect(expanded ? .zero : .degrees(180))
- }
- .accessibilityLabel(expanded ? "Collapse" : "Expand")
- .frame(width: 20, height: 20)
- }
-
- private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
- guard case let .emoji(query) = autocompleteState else {
- emojis = []
- return
- }
-
- mastodonController.getCustomEmojis { (emojis) in
- var emojis = emojis
- if !query.isEmpty {
- emojis =
- emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
- (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
- }
- .filter(\.1.matched)
- .sorted { $0.1.score > $1.1.score }
- .map(\.0)
- }
- var shortcodes = Set()
- self.emojis = []
- for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
- self.emojis.append(emoji)
- shortcodes.insert(emoji.shortcode)
- }
- }
- }
-}
-
-struct ComposeAutocompleteHashtagsView: View {
- @EnvironmentObject private var mastodonController: MastodonController
- @EnvironmentObject private var uiState: ComposeUIState
-
- @State private var hashtags: [Hashtag] = []
- @State private var trendingRequest: URLSessionTask?
- @State private var searchRequest: URLSessionTask?
-
- var body: some View {
- ScrollView(.horizontal) {
- HStack(spacing: 8) {
- ForEach(hashtags, id: \.name) { (hashtag) in
- Button {
- uiState.currentInput?.autocomplete(with: "#\(hashtag.name)")
- } label: {
- Text(verbatim: "#\(hashtag.name)")
- .foregroundColor(Color(UIColor.label))
- }
- .frame(height: 30)
- .padding(.vertical, 8)
- }
- .animation(.linear(duration: 0.1), value: hashtags)
-
- Spacer()
- }
- .padding(.horizontal, 8)
- }
- .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
- .onDisappear {
- trendingRequest?.cancel()
- }
- }
-
- private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
- guard case let .hashtag(query) = autocompleteState,
- !query.isEmpty else {
- hashtags = []
- return
- }
-
- let onlySavedTagsWorkItem = DispatchWorkItem {
- self.updateHashtags(searchResults: [], trendingTags: [], query: query)
- }
-
- // we only want to do the local-only search if the trends API call takes more than .25sec or it fails
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onlySavedTagsWorkItem)
-
- var trendingTags: [Hashtag] = []
- var searchedTags: [Hashtag] = []
-
- let group = DispatchGroup()
-
- group.enter()
- trendingRequest = mastodonController.run(Client.getTrendingHashtags()) { (response) in
- defer { group.leave() }
- guard case let .success(trends, _) = response else { return }
- trendingTags = trends
- }
-
- group.enter()
- searchRequest = mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])) { (response) in
- defer { group.leave() }
- guard case let .success(results, _) = response else { return }
- searchedTags = results.hashtags
- }
-
- group.notify(queue: .main) {
- onlySavedTagsWorkItem.cancel()
-
- // if the query has changed, don't bother loading the now-outdated results
- if case .hashtag(query) = self.uiState.autocompleteState {
- self.updateHashtags(searchResults: searchedTags, trendingTags: trendingTags, query: query)
- }
- }
- }
-
- private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) {
- let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
- let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [])
- .map { Hashtag(name: $0.name, url: $0.url) }
-
- hashtags = (searchResults + savedTags + trendingTags)
- .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in
- return (tag, FuzzyMatcher.match(pattern: query, str: tag.name))
- }
- .filter(\.1.matched)
- .sorted { $0.1.score > $1.1.score }
- .map(\.0)
- }
-}
-
-struct ComposeAutocompleteView_Previews: PreviewProvider {
- static var previews: some View {
- ComposeAutocompleteView(autocompleteState: .mention("shadowfacts"))
- }
-}
diff --git a/Tusker/Screens/Compose/ComposeAvatarImageView.swift b/Tusker/Screens/Compose/ComposeAvatarImageView.swift
deleted file mode 100644
index eceb9c3a..00000000
--- a/Tusker/Screens/Compose/ComposeAvatarImageView.swift
+++ /dev/null
@@ -1,64 +0,0 @@
-//
-// ComposeAvatarImageView.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/18/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-
-struct ComposeAvatarImageView: View {
- let url: URL?
- @State var request: ImageCache.Request? = nil
- @State var avatarImage: UIImage? = nil
- @ObservedObject var preferences = Preferences.shared
-
- var body: some View {
- image
- .resizable()
- .conditionally(url != nil) {
- $0.onAppear(perform: self.loadImage)
- }
- .onDisappear(perform: self.cancelRequest)
- }
-
- private var image: Image {
- if let avatarImage = avatarImage {
- return Image(uiImage: avatarImage).renderingMode(.original)
- } else {
- return placeholderImage
- }
- }
-
- private var placeholderImage: Image {
- let imageName: String
- switch preferences.avatarStyle {
- case .circle:
- imageName = "person.crop.circle"
- case .roundRect:
- imageName = "person.crop.square"
- }
- return Image(systemName: imageName)
- }
-
- private func loadImage() {
- guard let url = url else { return }
- request = ImageCache.avatars.get(url) { (_, image) in
- DispatchQueue.main.async {
- self.request = nil
- self.avatarImage = image
- }
- }
- }
-
- private func cancelRequest() {
- request?.cancel()
- }
-}
-
-struct ComposeAvatarImageView_Previews: PreviewProvider {
- static var previews: some View {
- ComposeAvatarImageView(url: URL(string: "https://social.shadowfacts.net/media/4b481afc591a8f3d11d0f5732e5cb320422dec72d7f223ebb5f35d5d0e821a9c.png")!)
- }
-}
diff --git a/Tusker/Screens/Compose/ComposeCurrentAccount.swift b/Tusker/Screens/Compose/ComposeCurrentAccount.swift
deleted file mode 100644
index 878c6139..00000000
--- a/Tusker/Screens/Compose/ComposeCurrentAccount.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-//
-// ComposeCurrentAccount.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/18/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-import Pachyderm
-
-struct ComposeCurrentAccount: View {
- @EnvironmentObject var mastodonController: MastodonController
- @ObservedObject private var preferences = Preferences.shared
-
- var account: Account? {
- mastodonController.account
- }
-
- var body: some View {
- HStack(alignment: .top) {
- ComposeAvatarImageView(url: account?.avatar)
- .frame(width: 50, height: 50)
- .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
- .accessibilityHidden(true)
-
- if let id = account?.id,
- let account = mastodonController.persistentContainer.account(for: id) {
- VStack(alignment: .leading) {
- AccountDisplayNameLabel(account: account, textStyle: .title2, emojiSize: 24)
- .lineLimit(1)
-
- Text(verbatim: "@\(account.acct)")
- .font(.body.weight(.light))
- .foregroundColor(.secondary)
- .lineLimit(1)
- }
- }
-
- Spacer()
- }
- }
-}
-
-//struct ComposeCurrentAccount_Previews: PreviewProvider {
-// static var previews: some View {
-// ComposeCurrentAccount(account: )
-// }
-//}
diff --git a/Tusker/Screens/Compose/ComposeEmojiTextField.swift b/Tusker/Screens/Compose/ComposeEmojiTextField.swift
deleted file mode 100644
index cfaceb8d..00000000
--- a/Tusker/Screens/Compose/ComposeEmojiTextField.swift
+++ /dev/null
@@ -1,269 +0,0 @@
-//
-// ComposeContentWarningTextField.swift
-// Tusker
-//
-// Created by Shadowfacts on 10/12/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-
-struct ComposeEmojiTextField: UIViewRepresentable {
- typealias UIViewType = UITextField
-
- @EnvironmentObject private var uiState: ComposeUIState
-
- @Binding var text: String
- let placeholder: String
- let maxLength: Int?
- let becomeFirstResponder: Binding?
- let focusNextView: Binding?
- private var didChange: ((String) -> Void)? = nil
- private var didEndEditing: (() -> Void)? = nil
- private var backgroundColor: UIColor? = nil
-
- init(text: Binding, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = nil) {
- self._text = text
- self.placeholder = placeholder
- self.maxLength = maxLength
- self.becomeFirstResponder = becomeFirstResponder
- self.focusNextView = focusNextView
- self.didChange = nil
- self.didEndEditing = nil
- }
-
- mutating func didChange(_ didChange: @escaping (String) -> Void) -> Self {
- self.didChange = didChange
- return self
- }
-
- mutating func didEndEditing(_ didEndEditing: @escaping () -> Void) -> Self {
- self.didEndEditing = didEndEditing
- return self
- }
-
- mutating func backgroundColor(_ color: UIColor) -> Self {
- self.backgroundColor = color
- return self
- }
-
- func makeUIView(context: Context) -> UITextField {
- let view = UITextField()
-
- view.placeholder = placeholder
- view.borderStyle = .roundedRect
- view.font = .preferredFont(forTextStyle: .body)
- view.adjustsFontForContentSizeCategory = true
- view.backgroundColor = backgroundColor
-
- view.delegate = context.coordinator
- view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
- view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
-
- // otherwise when the text gets too wide it starts expanding the ComposeView
- view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-
- context.coordinator.textField = view
- context.coordinator.uiState = uiState
- context.coordinator.text = $text
-
- return view
- }
-
- func updateUIView(_ uiView: UITextField, context: Context) {
- if context.coordinator.skipSettingTextOnNextUpdate {
- context.coordinator.skipSettingTextOnNextUpdate = false
- } else {
- uiView.text = text
- }
- context.coordinator.maxLength = maxLength
- context.coordinator.didChange = didChange
- context.coordinator.didEndEditing = didEndEditing
- context.coordinator.focusNextView = focusNextView
-
- if becomeFirstResponder?.wrappedValue == true {
- DispatchQueue.main.async {
- uiView.becomeFirstResponder()
- becomeFirstResponder?.wrappedValue = false
- }
- }
- }
-
- func makeCoordinator() -> Coordinator {
- return Coordinator()
- }
-
- class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
- weak var textField: UITextField?
- var text: Binding!
- // break retained cycle through ComposeUIState.currentInput
- unowned var uiState: ComposeUIState!
- var maxLength: Int?
- var didChange: ((String) -> Void)?
- var didEndEditing: (() -> Void)?
- var focusNextView: Binding?
-
- var skipSettingTextOnNextUpdate = false
-
- var toolbarElements: [ComposeUIState.ToolbarElement] {
- [.emojiPicker]
- }
-
- @objc func didChange(_ textField: UITextField) {
- text.wrappedValue = textField.text ?? ""
- didChange?(text.wrappedValue)
- }
-
- @objc func returnKeyPressed() {
- focusNextView?.wrappedValue = true
- }
-
- func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
- if let maxLength {
- return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
- } else {
- return true
- }
- }
-
- func textFieldDidBeginEditing(_ textField: UITextField) {
- uiState.currentInput = self
- updateAutocompleteState(textField: textField)
- }
-
- func textFieldDidEndEditing(_ textField: UITextField) {
- uiState.currentInput = nil
- updateAutocompleteState(textField: textField)
- didEndEditing?()
- }
-
- func textFieldDidChangeSelection(_ textField: UITextField) {
- // see MainComposeTextView.Coordinator.textViewDidChangeSelection(_:)
- skipSettingTextOnNextUpdate = true
- self.updateAutocompleteState(textField: textField)
- }
-
- func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
- var actions = suggestedActions
- if range.length == 0 {
- actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
- self?.uiState.shouldEmojiAutocompletionBeginExpanded = true
- self?.beginAutocompletingEmoji()
- }))
- }
- return UIMenu(children: actions)
- }
-
- func beginAutocompletingEmoji() {
- textField?.insertText(":")
- }
-
- func applyFormat(_ format: StatusFormat) {
- }
-
- func autocomplete(with string: String) {
- guard let textField = textField,
- let text = textField.text,
- let selectedRange = textField.selectedTextRange,
- let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else {
- return
- }
-
- let distanceToEnd = textField.offset(from: selectedRange.start, to: textField.endOfDocument)
-
- let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
- let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
-
- let insertSpace: Bool
- if distanceToEnd > 0 {
- let charAfterCursor = text[characterBeforeCursorIndex]
- insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
- } else {
- insertSpace = true
- }
- let string = insertSpace ? string + " " : string
-
- textField.text!.replaceSubrange(lastWordStartIndex.. text.startIndex {
- let c = text[text.index(before: lastWordStartIndex)]
- if isPermittedForAutocomplete(c) || c == ":" {
- uiState.autocompleteState = nil
- return
- }
- }
-
- let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
- let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
-
- if lastWordStartIndex >= text.startIndex {
- let lastWord = text[lastWordStartIndex.. Bool {
- return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
- }
-
- private func findAutocompleteLastWord(textField: UITextField) -> String.Index? {
- guard textField.isFirstResponder,
- let selectedRange = textField.selectedTextRange,
- selectedRange.isEmpty,
- let text = textField.text,
- !text.isEmpty else {
- return nil
- }
-
- let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
- let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
-
- guard cursorIndex != text.startIndex else {
- return nil
- }
-
- var lastWordStartIndex = text.index(before: cursorIndex)
- while true {
- let c = text[lastWordStartIndex]
-
- if !isPermittedForAutocomplete(c) {
- break
- }
-
- if lastWordStartIndex > text.startIndex {
- lastWordStartIndex = text.index(before: lastWordStartIndex)
- } else {
- break
- }
- }
-
- return lastWordStartIndex
- }
- }
-
-}
diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift
index d346604d..8ccfdc9c 100644
--- a/Tusker/Screens/Compose/ComposeHostingController.swift
+++ b/Tusker/Screens/Compose/ComposeHostingController.swift
@@ -2,277 +2,222 @@
// ComposeHostingController.swift
// Tusker
//
-// Created by Shadowfacts on 8/22/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
+// Created by Shadowfacts on 3/6/23.
+// Copyright © 2023 Shadowfacts. All rights reserved.
//
import SwiftUI
+import ComposeUI
import Combine
-import Pachyderm
+import PhotosUI
import PencilKit
+import Pachyderm
+import CoreData
import Duckable
protocol ComposeHostingControllerDelegate: AnyObject {
- func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
+ func dismissCompose(mode: DismissMode) -> Bool
}
-class ComposeHostingController: UIHostingController, DuckableViewController {
+class ComposeHostingController: UIHostingController, DuckableViewController {
weak var delegate: ComposeHostingControllerDelegate?
weak var duckableDelegate: DuckableViewControllerDelegate?
+ let controller: ComposeController
let mastodonController: MastodonController
- let uiState: ComposeUIState
+ private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
+ private var drawingCompletion: ((PKDrawing) -> Void)?
- var draft: Draft { uiState.draft }
-
- private var cancellables = [AnyCancellable]()
-
- init(draft: Draft? = nil, mastodonController: MastodonController) {
- self.mastodonController = mastodonController
- let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
- DraftsManager.shared.add(realDraft)
+ init(draft: Draft?, mastodonController: MastodonController) {
+ let draft = draft ?? mastodonController.createDraft()
+ DraftsManager.shared.add(draft)
- self.uiState = ComposeUIState(draft: realDraft)
-
- let wrapper = Wrapper(
+ self.controller = ComposeController(
+ draft: draft,
+ config: ComposeUIConfig(),
mastodonController: mastodonController,
- uiState: uiState
+ fetchAvatar: { 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)) },
+ emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
)
- super.init(rootView: wrapper)
+ controller.currentAccount = mastodonController.account
- self.uiState.delegate = self
- pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
- userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
+ self.mastodonController = mastodonController
- updateNavigationTitle(draft: uiState.draft)
+ super.init(rootView: View(mastodonController: mastodonController, controller: controller))
- self.uiState.$draft
- .flatMap(\.objectWillChange)
- .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
- .sink {
- DraftsManager.save()
- }
- .store(in: &cancellables)
+ self.updateConfig()
- self.uiState.$draft
- .sink { [unowned self] draft in
- self.updateNavigationTitle(draft: draft)
- }
- .store(in: &cancellables)
+ pasteConfiguration = UIPasteConfiguration(forAccepting: ComposeUI.DraftAttachment.self)
+
+ NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
- private func updateNavigationTitle(draft: Draft) {
- if let id = draft.inReplyToID,
- let status = mastodonController.persistentContainer.status(for: id) {
- navigationItem.title = "Reply to @\(status.account.acct)"
- } else {
- navigationItem.title = "New Post"
+ @objc private func updateConfig() {
+ var config = ComposeUIConfig()
+ config.backgroundColor = .appBackground
+ config.groupedBackgroundColor = .appGroupedBackground
+ config.groupedCellBackgroundColor = .appGroupedCellBackground
+ config.fillColor = .appFill
+ switch Preferences.shared.avatarStyle {
+ case .roundRect:
+ config.avatarStyle = .roundRect
+ case .circle:
+ config.avatarStyle = .circle
}
- }
-
- override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- if !draft.hasContent {
- DraftsManager.shared.remove(draft)
+ config.useTwitterKeyboard = Preferences.shared.useTwitterKeyboard
+ config.contentType = Preferences.shared.statusContentType
+ config.automaticallySaveDrafts = Preferences.shared.automaticallySaveDrafts
+ config.requireAttachmentDescriptions = Preferences.shared.requireAttachmentDescriptions
+
+ config.dismiss = { [unowned self] in self.dismiss(mode: $0) }
+ config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) }
+ config.presentDrawing = { [unowned self] in self.presentDrawing($0, completion: $1) }
+ config.userActivityForDraft = { [unowned self] in
+ let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: self.mastodonController.accountInfo!.id)
+ activity.displaysAuxiliaryScene = true
+ return NSItemProvider(object: activity)
}
- DraftsManager.save()
+
+ controller.config = config
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
- guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
- if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
- guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false }
- // todo: if providers are videos, this technically allows invalid video/image combinations
- return itemProviders.count + draft.attachments.count <= 4
- } else {
- return true
- }
+ return controller.canPaste(itemProviders: itemProviders)
}
override func paste(itemProviders: [NSItemProvider]) {
- for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) {
- provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
- guard let attachment = object as? CompositionAttachment else { return }
- DispatchQueue.main.async {
- self.draft.attachments.append(attachment)
- }
- }
+ controller.paste(itemProviders: itemProviders)
+ }
+
+ private func dismiss(mode: DismissMode) {
+ if delegate?.dismissCompose(mode: mode) == true {
+ return
+ } else {
+ dismiss(animated: true)
+ duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
}
}
- override func accessibilityPerformEscape() -> Bool {
- dismissCompose(mode: .cancel)
- return true
+ private func presentAssetPicker(completion: @MainActor @escaping ([PHPickerResult]) -> Void) {
+ self.assetPickerCompletion = completion
+
+ var config = PHPickerConfiguration()
+ config.selection = .ordered
+ config.selectionLimit = 0
+ config.preferredAssetRepresentationMode = .compatible
+ let picker = PHPickerViewController(configuration: config)
+ picker.delegate = self
+ picker.modalPresentationStyle = .pageSheet
+ picker.overrideUserInterfaceStyle = .dark
+ // sheet detents don't play nice with PHPickerViewController, see
+// let sheet = picker.sheetPresentationController!
+// sheet.detents = [.medium(), .large()]
+// sheet.prefersEdgeAttachedInCompactHeight = true
+// sheet.prefersGrabberVisible = true
+ present(picker, animated: true)
+ }
+
+ private func presentDrawing(_ drawing: PKDrawing, completion: @escaping (PKDrawing) -> Void) {
+ self.drawingCompletion = completion
+
+ present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
}
// MARK: Duckable
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
withAnimation(.linear(duration: duration).delay(delay)) {
- uiState.isDucking = true
+ controller.showToolbar = false
}
}
func duckableViewControllerDidFinishAnimatingDuck() {
- uiState.isDucking = false
+ controller.showToolbar = true
}
- // MARK: Interaction
-
- @objc func cwButtonPressed() {
- draft.contentWarningEnabled = !draft.contentWarningEnabled
- }
-
- @objc func formatButtonPressed(_ sender: UIBarButtonItem) {
- let format = StatusFormat.allCases[sender.tag]
- uiState.currentInput?.applyFormat(format)
- }
-
- @objc func emojiPickerButtonPressed() {
- guard uiState.autocompleteState == nil else {
- return
- }
- uiState.shouldEmojiAutocompletionBeginExpanded = true
- uiState.currentInput?.beginAutocompletingEmoji()
- }
-
- @objc func draftsButtonPresed() {
- uiState.isShowingDraftsList = true
- }
-
-}
-
-extension ComposeHostingController {
- struct Wrapper: View {
+ struct View: SwiftUI.View {
let mastodonController: MastodonController
- @ObservedObject var uiState: ComposeUIState
- var draft: Draft {
- uiState.draft
- }
+ let controller: ComposeController
- var body: some View {
- ComposeView()
- .environmentObject(mastodonController)
- .environmentObject(uiState)
- .environmentObject(draft)
+ var body: some SwiftUI.View {
+ ControllerView(controller: { controller })
+ .task {
+ if let account = try? await mastodonController.getOwnAccount() {
+ controller.currentAccount = account
+ }
+ }
}
}
}
-extension ComposeHostingController: ComposeUIStateDelegate {
- var assetPickerDelegate: AssetPickerViewControllerDelegate? { self }
-
- func dismissCompose(mode: ComposeUIState.DismissMode) {
- let dismissed = delegate?.dismissCompose(mode: mode) ?? false
- if !dismissed {
- self.dismiss(animated: true)
- self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
- }
- }
-
- func presentAssetPickerSheet() {
- let picker = AssetPickerViewController()
- picker.assetPickerDelegate = self
- picker.modalPresentationStyle = .pageSheet
- picker.overrideUserInterfaceStyle = .dark
- let sheet = picker.sheetPresentationController!
- sheet.detents = [.medium(), .large()]
- sheet.prefersEdgeAttachedInCompactHeight = true
- self.present(picker, animated: true)
- }
-
- func presentComposeDrawing() {
- let drawing: PKDrawing
+extension MastodonController: ComposeMastodonContext {
+ @MainActor
+ func searchCachedAccounts(query: String) -> [AccountProtocol] {
+ // todo: there's got to be something more efficient than this :/
+ let wildcardedQuery = query.map { "*\($0)" }.joined() + "*"
+ let request: NSFetchRequest = AccountMO.fetchRequest()
+ request.predicate = NSPredicate(format: "displayName LIKE[cd] %@ OR acct LIKE[cd] %@", wildcardedQuery, wildcardedQuery)
- if case let .edit(id) = uiState.composeDrawingMode,
- let attachment = draft.attachments.first(where: { $0.id == id }),
- case let .drawing(existingDrawing) = attachment.data {
- drawing = existingDrawing
+ if let results = try? persistentContainer.viewContext.fetch(request) {
+ return results
} else {
- drawing = PKDrawing()
+ return []
}
-
- present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
}
- func selectDraft(_ draft: Draft) {
- if self.draft.hasContent {
- DraftsManager.save()
- } else {
- DraftsManager.shared.remove(self.draft)
+ @MainActor
+ func cachedRelationship(for accountID: String) -> RelationshipProtocol? {
+ return persistentContainer.relationship(forAccount: accountID)
+ }
+
+ @MainActor
+ func searchCachedHashtags(query: String) -> [Hashtag] {
+ let wildcardedQuery = query.map { "*\($0)" }.joined() + "*"
+ let predicate = NSPredicate(format: "name LIKE[cd] %@", wildcardedQuery)
+ let savedReq = SavedHashtag.fetchRequest(account: accountInfo!)
+ savedReq.predicate = predicate
+ let followedReq = FollowedHashtag.fetchRequest()
+ followedReq.predicate = predicate
+
+ let saved = try? persistentContainer.viewContext.fetch(savedReq).map { Hashtag(name: $0.name, url: $0.url) }
+ let followed = try? persistentContainer.viewContext.fetch(followedReq).map { Hashtag(name: $0.name, url: $0.url) }
+
+ var results = saved ?? []
+ if let followed {
+ results.append(contentsOf: followed)
}
- uiState.draft = draft
- uiState.isShowingDraftsList = false
+ return results
}
}
-extension ComposeHostingController: AssetPickerViewControllerDelegate {
- func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
- if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
- if (type == .video && draft.attachments.count > 0) ||
- draft.attachments.contains(where: { $0.data.type == .video }) ||
- assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) {
- return false
- }
- return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4
- } else {
- return true
- }
- }
-
- func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) {
- let attachments = attachments.map {
- CompositionAttachment(data: $0)
- }
- withAnimation {
- draft.attachments.append(contentsOf: attachments)
- }
- }
-}
-
-// superseded by duckable stuff
-@available(iOS, obsoleted: 16.0)
-extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
- func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
- return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
- }
-
- func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
- UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: self, for: nil)
- }
-
- func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
- uiState.isShowingSaveDraftSheet = true
- }
-
- func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
- DraftsManager.save()
+extension ComposeHostingController: PHPickerViewControllerDelegate {
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ dismiss(animated: true)
+
+ assetPickerCompletion?(results)
+ assetPickerCompletion = nil
}
}
extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
dismiss(animated: true)
+ drawingCompletion = nil
}
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
- switch uiState.composeDrawingMode {
- case nil, .createNew:
- let attachment = CompositionAttachment(data: .drawing(drawing))
- draft.attachments.append(attachment)
-
- case let .edit(id):
- let existing = draft.attachments.first { $0.id == id }
- existing?.data = .drawing(drawing)
- }
-
dismiss(animated: true)
+ drawingCompletion?(drawing)
+ drawingCompletion = nil
}
}
diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift
deleted file mode 100644
index 6fb187b6..00000000
--- a/Tusker/Screens/Compose/ComposePollView.swift
+++ /dev/null
@@ -1,231 +0,0 @@
-//
-// ComposePollView.swift
-// Tusker
-//
-// Created by Shadowfacts on 4/28/21.
-// Copyright © 2021 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-
-struct ComposePollView: View {
- private static let formatter: DateComponentsFormatter = {
- let f = DateComponentsFormatter()
- f.maximumUnitCount = 1
- f.unitsStyle = .full
- f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
- return f
- }()
-
- @ObservedObject var draft: Draft
- @ObservedObject var poll: Draft.Poll
-
- @EnvironmentObject var mastodonController: MastodonController
- @Environment(\.colorScheme) var colorScheme: ColorScheme
-
- @State private var duration: Duration
-
- init(draft: Draft, poll: Draft.Poll) {
- self.draft = draft
- self.poll = poll
-
- self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay)
- }
-
- private var canAddOption: Bool {
- if let pollConfig = mastodonController.instance?.pollsConfiguration {
- return poll.options.count < pollConfig.maxOptions
- } else {
- return true
- }
- }
-
- var body: some View {
- VStack {
- HStack {
- Text("Poll")
- .font(.headline)
-
- Spacer()
-
- Button(action: self.removePoll) {
- Image(systemName: "xmark")
- .imageScale(.small)
- .padding(4)
- }
- .accessibilityLabel("Remove poll")
- .buttonStyle(.plain)
- .accentColor(buttonForegroundColor)
- .background(Circle().foregroundColor(buttonBackgroundColor))
- .hoverEffect()
- }
-
- List {
- ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
- ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
- .frame(height: 36)
- .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
- .listRowSeparator(.hidden)
- .listRowBackground(Color.clear)
- }
- .onMove { indices, newIndex in
- poll.options.move(fromOffsets: indices, toOffset: newIndex)
- }
- }
- .listStyle(.plain)
- .frame(height: 44 * CGFloat(poll.options.count))
-
- Button(action: self.addOption) {
- Label {
- Text("Add Option")
- } icon: {
- Image(systemName: "plus")
- .foregroundColor(.accentColor)
- }
- }
- .buttonStyle(.borderless)
- .disabled(!canAddOption)
-
- HStack {
- MenuPicker(selection: $poll.multiple, options: [
- .init(value: true, title: "Allow multiple"),
- .init(value: false, title: "Single choice"),
- ])
- .frame(maxWidth: .infinity)
-
- MenuPicker(selection: $duration, options: Duration.allCases.map {
- .init(value: $0, title: ComposePollView.formatter.string(from: $0.timeInterval)!)
- })
- .frame(maxWidth: .infinity)
- }
- }
- .padding(8)
- .background(
- backgroundColor
- .cornerRadius(10)
- )
- .onChange(of: duration, perform: { (value) in
- poll.duration = value.timeInterval
- })
- }
-
- private var backgroundColor: Color {
- // in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
- colorScheme == .dark ? Color.appFill : Color(white: 0.95)
- }
-
- private var buttonBackgroundColor: Color {
- Color(white: colorScheme == .dark ? 0.1 : 0.8)
- }
-
- private var buttonForegroundColor: Color {
- Color(UIColor.label)
- }
-
- private func removePoll() {
- withAnimation {
- self.draft.poll = nil
- }
- }
-
- private func addOption() {
- poll.options.append(Draft.Poll.Option(""))
- }
-}
-
-extension ComposePollView {
- enum Duration: Hashable, Equatable, CaseIterable {
- case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
-
- static func fromTimeInterval(_ ti: TimeInterval) -> Duration? {
- for it in allCases where it.timeInterval == ti {
- return it
- }
- return nil
- }
-
- var timeInterval: TimeInterval {
- switch self {
- case .fiveMinutes:
- return 5 * 60
- case .thirtyMinutes:
- return 30 * 60
- case .oneHour:
- return 60 * 60
- case .sixHours:
- return 6 * 60 * 60
- case .oneDay:
- return 24 * 60 * 60
- case .threeDays:
- return 3 * 24 * 60 * 60
- case .sevenDays:
- return 7 * 24 * 60 * 60
- }
- }
- }
-}
-
-struct ComposePollOption: View {
- @ObservedObject var poll: Draft.Poll
- @ObservedObject var option: Draft.Poll.Option
- let optionIndex: Int
-
- @EnvironmentObject private var mastodonController: MastodonController
-
- var body: some View {
- HStack(spacing: 4) {
- Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2)
- .animation(.default, value: poll.multiple)
-
- textField
-
- Button(action: self.removeOption) {
- Image(systemName: "minus.circle.fill")
- }
- .buttonStyle(.plain)
- .foregroundColor(poll.options.count == 1 ? .gray : .red)
- .disabled(poll.options.count == 1)
- .hoverEffect()
- }
- }
-
- private var textField: some View {
- var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption)
- return field.backgroundColor(.appBackground)
- }
-
- private func removeOption() {
- poll.options.remove(at: optionIndex)
- }
-
- struct Checkbox: View {
- private let radiusFraction: CGFloat
- private let size: CGFloat = 20
- private let innerSize: CGFloat
-
- init(radiusFraction: CGFloat, borderWidth: CGFloat) {
- self.radiusFraction = radiusFraction
- self.innerSize = self.size - 2 * borderWidth
- }
-
- var body: some View {
- ZStack {
- Rectangle()
- .foregroundColor(.gray)
- .frame(width: size, height: size)
- .cornerRadius(radiusFraction * size)
-
- Rectangle()
- .foregroundColor(Color(UIColor.appBackground))
- .frame(width: innerSize, height: innerSize)
- .cornerRadius(radiusFraction * innerSize)
- }
- }
- }
-}
-
-//struct ComposePollView_Previews: PreviewProvider {
-// static var previews: some View {
-// ComposePollView()
-// }
-//}
diff --git a/Tusker/Screens/Compose/ComposeReplyContentView.swift b/Tusker/Screens/Compose/ComposeReplyContentView.swift
index 19b47daa..199a1f28 100644
--- a/Tusker/Screens/Compose/ComposeReplyContentView.swift
+++ b/Tusker/Screens/Compose/ComposeReplyContentView.swift
@@ -7,26 +7,31 @@
//
import SwiftUI
+import Pachyderm
struct ComposeReplyContentView: UIViewRepresentable {
typealias UIViewType = ComposeReplyContentTextView
- let status: StatusMO
-
- @EnvironmentObject var mastodonController: MastodonController
+ let status: any StatusProtocol
+ let mastodonController: MastodonController
let heightChanged: (CGFloat) -> Void
func makeUIView(context: Context) -> UIViewType {
let view = ComposeReplyContentTextView()
- view.overrideMastodonController = mastodonController
- view.setTextFrom(status: status)
view.isUserInteractionEnabled = false
// scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line
view.isScrollEnabled = true
view.backgroundColor = .clear
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ view.adjustsFontForContentSizeCategory = true
+ view.defaultFont = TimelineStatusCollectionViewCell.contentFont
+ view.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
+
+ view.overrideMastodonController = mastodonController
+ view.setTextFrom(status: status)
+
return view
}
diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift
deleted file mode 100644
index f3b7ba8f..00000000
--- a/Tusker/Screens/Compose/ComposeToolbar.swift
+++ /dev/null
@@ -1,148 +0,0 @@
-//
-// ComposeToolbar.swift
-// Tusker
-//
-// Created by Shadowfacts on 11/12/22.
-// Copyright © 2022 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-import Pachyderm
-
-struct ComposeToolbar: View {
- static let height: CGFloat = 44
- private static let visibilityOptions: [MenuPicker.Option] = Status.Visibility.allCases.map { vis in
- .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
- }
-
- @ObservedObject var draft: Draft
-
- @EnvironmentObject private var uiState: ComposeUIState
- @EnvironmentObject private var mastodonController: MastodonController
- @ObservedObject private var preferences = Preferences.shared
- @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
- @State private var minWidth: CGFloat?
- @State private var realWidth: CGFloat?
-
- var body: some View {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 0) {
- Button("CW") {
- draft.contentWarningEnabled.toggle()
- }
- .accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
- .padding(5)
- .hoverEffect()
-
- MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly)
-// // the button has a bunch of extra space by default, but combined with what we add it's too much
- .padding(.horizontal, -8)
-
- if mastodonController.instanceFeatures.localOnlyPosts {
- MenuPicker(selection: $draft.localOnly, options: [
- .init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")),
- .init(value: false, title: "Federated", image: UIImage(systemName: "link"))
- ], buttonStyle: .iconOnly)
- .padding(.horizontal, -8)
- }
-
- if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) {
- Button(action: self.emojiPickerButtonPressed) {
- Label("Insert custom emoji", systemImage: "face.smiling")
- }
- .labelStyle(.iconOnly)
- .font(.system(size: imageSize))
- .padding(5)
- .hoverEffect()
- .transition(.opacity.animation(.linear(duration: 0.2)))
- }
-
- if let currentInput = uiState.currentInput,
- currentInput.toolbarElements.contains(.formattingButtons),
- preferences.statusContentType != .plain {
- Spacer()
-
- ForEach(StatusFormat.allCases, id: \.rawValue) { format in
- Button(action: self.formatAction(format)) {
- if let imageName = format.imageName {
- Image(systemName: imageName)
- .font(.system(size: imageSize))
- } else if let (str, attrs) = format.title {
- let container = try! AttributeContainer(attrs, including: \.uiKit)
- Text(AttributedString(str, attributes: container))
- }
- }
- .accessibilityLabel(format.accessibilityLabel)
- .padding(5)
- .hoverEffect()
- .transition(.opacity.animation(.linear(duration: 0.2)))
- }
- }
-
- Spacer()
- }
- .padding(.horizontal, 16)
- .frame(minWidth: minWidth)
- .background(GeometryReader { proxy in
- Color.clear
- .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
- .onPreferenceChange(ToolbarWidthPrefKey.self) { width in
- realWidth = width
- }
- })
- }
- .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
- .frame(height: Self.height)
- .frame(maxWidth: .infinity)
- .background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
- .overlay(alignment: .top) {
- Divider()
- }
- .background(GeometryReader { proxy in
- Color.clear
- .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
- .onPreferenceChange(ToolbarWidthPrefKey.self) { width in
- minWidth = width
- }
- })
- }
-
- private func emojiPickerButtonPressed() {
- guard uiState.autocompleteState == nil else {
- return
- }
- uiState.shouldEmojiAutocompletionBeginExpanded = true
- uiState.currentInput?.beginAutocompletingEmoji()
- }
-
- private func formatAction(_ format: StatusFormat) -> () -> Void {
- {
- uiState.currentInput?.applyFormat(format)
- }
- }
-}
-
-private struct ToolbarWidthPrefKey: PreferenceKey {
- static var defaultValue: CGFloat? = nil
- static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
- value = nextValue()
- }
-}
-
-private extension View {
- @available(iOS, obsoleted: 16.0)
- @ViewBuilder
- func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
- if #available(iOS 16.0, *) {
- self.scrollDisabled(disabled)
- } else {
- self
- }
- }
-}
-
-struct ComposeToolbar_Previews: PreviewProvider {
- static var previews: some View {
- ComposeToolbar(draft: Draft(accountID: ""))
- }
-}
diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift
deleted file mode 100644
index f5d40ffc..00000000
--- a/Tusker/Screens/Compose/ComposeUIState.swift
+++ /dev/null
@@ -1,80 +0,0 @@
-//
-// ComposeUIState.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/24/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-
-protocol ComposeUIStateDelegate: AnyObject {
- var assetPickerDelegate: AssetPickerViewControllerDelegate? { get }
-
- func dismissCompose(mode: ComposeUIState.DismissMode)
- // @available(iOS, obsoleted: 16.0)
- func presentAssetPickerSheet()
- func presentComposeDrawing()
- func selectDraft(_ draft: Draft)
- func paste(itemProviders: [NSItemProvider])
-}
-
-class ComposeUIState: ObservableObject {
-
- weak var delegate: ComposeUIStateDelegate?
-
- @Published var draft: Draft
- @Published var isShowingSaveDraftSheet = false
- @Published var isShowingDraftsList = false
- @Published var attachmentsMissingDescriptions = Set()
- @Published var autocompleteState: AutocompleteState? = nil
- @Published var isDucking = false
-
- var composeDrawingMode: ComposeDrawingMode?
-
- var shouldEmojiAutocompletionBeginExpanded = false
- @Published var currentInput: ComposeInput?
-
- init(draft: Draft) {
- self.draft = draft
- }
-
-}
-
-extension ComposeUIState {
- enum ComposeDrawingMode {
- case createNew
- case edit(id: UUID)
- }
-}
-
-extension ComposeUIState {
- enum AutocompleteState: Equatable {
- case mention(String)
- case emoji(String)
- case hashtag(String)
- }
-}
-
-extension ComposeUIState {
- enum DismissMode {
- case cancel, post
- }
-}
-
-protocol ComposeInput: AnyObject {
- var toolbarElements: [ComposeUIState.ToolbarElement] { get }
-
- func autocomplete(with string: String)
-
- func applyFormat(_ format: StatusFormat)
-
- func beginAutocompletingEmoji()
-}
-
-extension ComposeUIState {
- enum ToolbarElement {
- case emojiPicker
- case formattingButtons
- }
-}
diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift
deleted file mode 100644
index 1d178f3a..00000000
--- a/Tusker/Screens/Compose/ComposeView.swift
+++ /dev/null
@@ -1,375 +0,0 @@
-//
-// ComposeView.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/18/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-import Pachyderm
-import Combine
-
-@propertyWrapper struct OptionalStateObject: DynamicProperty {
- private class Republisher: ObservableObject {
- var cancellable: AnyCancellable?
- var wrapped: T? {
- didSet {
- cancellable?.cancel()
- cancellable = wrapped?.objectWillChange
- .receive(on: RunLoop.main)
- .sink { [unowned self] _ in
- self.objectWillChange.send()
- }
- }
- }
- }
-
- @StateObject private var republisher = Republisher()
- @State private var object: T?
- var wrappedValue: T? {
- get {
- object
- }
- nonmutating set {
- object = newValue
- }
- }
-
- func update() {
- republisher.wrapped = wrappedValue
- }
-}
-
-struct ComposeView: View {
- @EnvironmentObject var mastodonController: MastodonController
- @EnvironmentObject var uiState: ComposeUIState
- @EnvironmentObject var draft: Draft
-
- @State private var globalFrameOutsideList: CGRect = .zero
- @State private var contentWarningBecomeFirstResponder = false
- @State private var mainComposeTextViewBecomeFirstResponder = false
- @StateObject private var keyboardReader = KeyboardReader()
-
- @OptionalStateObject private var poster: PostService?
- @State private var isShowingPostErrorAlert = false
- @State private var postError: PostService.Error?
- private var isPosting: Bool {
- poster != nil
- }
-
- private let stackPadding: CGFloat = 8
-
- private var charactersRemaining: Int {
- let limit = mastodonController.instanceFeatures.maxStatusChars
- let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
- return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance))
- }
-
- private var requiresAttachmentDescriptions: Bool {
- guard Preferences.shared.requireAttachmentDescriptions else { return false }
- let attachmentIds = draft.attachments.map(\.id)
- return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
- }
-
- private var validAttachmentCombination: Bool {
- if !mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
- return true
- } else if draft.attachments.contains(where: { $0.data.type == .video }) && draft.attachments.count > 1 {
- return false
- } else if draft.attachments.count > 4 {
- return false
- }
- return true
- }
-
- private var postButtonEnabled: Bool {
- draft.hasContent
- && charactersRemaining >= 0
- && !isPosting
- && !requiresAttachmentDescriptions
- && validAttachmentCombination
- && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
- }
-
- var body: some View {
- ZStack(alignment: .top) {
- // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
- Color.appBackground
- .edgesIgnoringSafeArea(.all)
-
- mainList
- .scrollDismissesKeyboardInteractivelyIfAvailable()
-
- if let poster = poster {
- // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
- WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
- }
- }
- .safeAreaInset(edge: .bottom, spacing: 0) {
- if !uiState.isDucking {
- VStack(spacing: 0) {
- autocompleteSuggestions
- .transition(.move(edge: .bottom))
- .animation(.default, value: uiState.autocompleteState)
-
- ComposeToolbar(draft: draft)
- }
- // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
- .padding(.bottom, keyboardInset)
- .transition(.move(edge: .bottom))
- }
- }
- .background(GeometryReader { proxy in
- Color.clear
- .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
- .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { frame in
- globalFrameOutsideList = frame
- }
- })
- .sheet(isPresented: $uiState.isShowingDraftsList) {
- DraftsRepresentable(currentDraft: draft, mastodonController: mastodonController)
- }
- .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
- .alert(isPresented: $isShowingPostErrorAlert) {
- Alert(
- title: Text("Error Posting Status"),
- message: Text(postError?.localizedDescription ?? ""),
- dismissButton: .default(Text("OK"))
- )
- }
- .toolbar {
- ToolbarItem(placement: .cancellationAction) { cancelButton }
- ToolbarItem(placement: .confirmationAction) { postButton }
- }
- }
-
- @available(iOS, obsoleted: 16.0)
- private var keyboardInset: CGFloat {
- if #unavailable(iOS 16.0),
- UIDevice.current.userInterfaceIdiom == .pad,
- keyboardReader.isVisible {
- return 44
- } else {
- return 0
- }
- }
-
- @ViewBuilder
- private var autocompleteSuggestions: some View {
- if let state = uiState.autocompleteState {
- ComposeAutocompleteView(autocompleteState: state)
- }
- }
-
- private var mainList: some View {
- List {
- if let id = draft.inReplyToID,
- let status = mastodonController.persistentContainer.status(for: id) {
- ComposeReplyView(
- status: status,
- rowTopInset: 8,
- globalFrameOutsideList: globalFrameOutsideList
- )
- .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
- .listRowSeparator(.hidden)
- .listRowBackground(Color.appBackground)
- }
-
- header
- .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
- .listRowSeparator(.hidden)
- .listRowBackground(Color.appBackground)
-
- if uiState.draft.contentWarningEnabled {
- ComposeEmojiTextField(
- text: $uiState.draft.contentWarning,
- placeholder: "Write your warning here",
- becomeFirstResponder: $contentWarningBecomeFirstResponder,
- focusNextView: $mainComposeTextViewBecomeFirstResponder
- )
- .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
- .listRowSeparator(.hidden)
- .listRowBackground(Color.appBackground)
- }
-
- MainComposeTextView(
- draft: draft,
- becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder
- )
- .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
- .listRowSeparator(.hidden)
- .listRowBackground(Color.appBackground)
-
- if let poll = draft.poll {
- ComposePollView(draft: draft, poll: poll)
- .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
- .listRowSeparator(.hidden)
- .listRowBackground(Color.appBackground)
- }
-
- ComposeAttachmentsList(
- draft: draft
- )
- .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
- .listRowBackground(Color.appBackground)
- }
- .animation(.default, value: draft.poll?.options.count)
- .scrollDismissesKeyboardInteractivelyIfAvailable()
- .listStyle(.plain)
- .disabled(isPosting)
- .onChange(of: draft.contentWarningEnabled) { newValue in
- if newValue {
- contentWarningBecomeFirstResponder = true
- }
- }
- }
-
- private var header: some View {
- HStack(alignment: .top) {
- ComposeCurrentAccount()
- .accessibilitySortPriority(1)
-
- Spacer()
-
- Text(verbatim: charactersRemaining.description)
- .foregroundColor(charactersRemaining < 0 ? .red : .secondary)
- .font(Font.body.monospacedDigit())
- .accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining"))
- // this should come first, so VO users can back to it from the main compose text view
- .accessibilitySortPriority(0)
- }.frame(height: 50)
- }
-
- private var cancelButton: some View {
- Button(action: self.cancel) {
- Text("Cancel")
- // otherwise all Buttons in the nav bar are made semibold
- .font(.system(size: 17, weight: .regular))
- }
- }
-
- @ViewBuilder
- private var postButton: some View {
- if draft.hasContent {
- Button {
- Task {
- await self.postStatus()
- }
- } label: {
- Text("Post")
- }
- .keyboardShortcut(.return, modifiers: .command)
- .disabled(!postButtonEnabled)
- } else {
- Button {
- uiState.isShowingDraftsList = true
- } label: {
- Text("Drafts")
- }
- }
- }
-
- private func cancel() {
- if Preferences.shared.automaticallySaveDrafts {
- // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear
- uiState.delegate?.dismissCompose(mode: .cancel)
- } else {
- // if the draft doesn't have content, it doesn't need to be saved
- if draft.hasContent {
- uiState.isShowingSaveDraftSheet = true
- } else {
- DraftsManager.shared.remove(draft)
- uiState.delegate?.dismissCompose(mode: .cancel)
- }
- }
- }
-
- private func saveAndCloseSheet() -> ActionSheet {
- ActionSheet(title: Text("Do you want to save the current post as a draft?"), buttons: [
- .default(Text("Save Draft"), action: {
- // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear
- uiState.isShowingSaveDraftSheet = false
- uiState.delegate?.dismissCompose(mode: .cancel)
- }),
- .destructive(Text("Delete Draft"), action: {
- DraftsManager.shared.remove(draft)
- uiState.isShowingSaveDraftSheet = false
- uiState.delegate?.dismissCompose(mode: .cancel)
- }),
- .cancel(),
- ])
- }
-
- private func postStatus() async {
- guard !isPosting,
- draft.hasContent else {
- return
- }
-
- let poster = PostService(mastodonController: mastodonController, draft: draft)
- self.poster = poster
-
- do {
- try await poster.post()
-
- // wait .25 seconds so the user can see the progress bar has completed
- try? await Task.sleep(nanoseconds: 250_000_000)
-
- uiState.delegate?.dismissCompose(mode: .post)
-
- } catch let error as PostService.Error {
- self.isShowingPostErrorAlert = true
- self.postError = error
- } catch {
- fatalError("Unreachable")
- }
-
- self.poster = nil
- }
-}
-
-extension View {
- @available(iOS, obsoleted: 16.0)
- @ViewBuilder
- func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
- if #available(iOS 16.0, *) {
- self.scrollDismissesKeyboard(.interactively)
- } else {
- self
- }
- }
-}
-
-private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
- static var defaultValue: CGRect = .zero
- static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
- value = nextValue()
- }
-}
-
-@available(iOS, obsoleted: 16.0)
-private class KeyboardReader: ObservableObject {
- @Published var isVisible = false
-
- init() {
- NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
- NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
- }
-
- @objc func willShow(_ notification: Foundation.Notification) {
- // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
- let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
- isVisible = endFrame.height > 72
- }
-
- @objc func willHide() {
- isVisible = false
- }
-}
-
-//struct ComposeView_Previews: PreviewProvider {
-// static var previews: some View {
-// ComposeView()
-// }
-//}
diff --git a/Tusker/Screens/Compose/DraftsView.swift b/Tusker/Screens/Compose/DraftsView.swift
deleted file mode 100644
index 4c872566..00000000
--- a/Tusker/Screens/Compose/DraftsView.swift
+++ /dev/null
@@ -1,144 +0,0 @@
-//
-// DraftsView.swift
-// Tusker
-//
-// Created by Shadowfacts on 11/9/22.
-// Copyright © 2022 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-
-@available(iOS, obsoleted: 16.0)
-struct DraftsRepresentable: UIViewControllerRepresentable {
- typealias UIViewControllerType = UIHostingController
-
- let currentDraft: Draft
- let mastodonController: MastodonController
-
- func makeUIViewController(context: Context) -> UIHostingController {
- return UIHostingController(rootView: DraftsView(currentDraft: currentDraft, mastodonController: mastodonController))
- }
-
- func updateUIViewController(_ uiViewController: UIHostingController, context: Context) {
- }
-}
-
-struct DraftsView: View {
- let currentDraft: Draft
- // don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something
- let mastodonController: MastodonController
- @EnvironmentObject var uiState: ComposeUIState
- @StateObject private var draftsManager = DraftsManager.shared
- @State private var draftForDifferentReply: Draft?
-
- private var visibleDrafts: [Draft] {
- draftsManager.sorted.filter {
- $0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id
- }
- }
-
- var body: some View {
- NavigationView {
- List {
- ForEach(visibleDrafts) { draft in
- Button {
- maybeSelectDraft(draft)
- } label: {
- DraftView(draft: draft)
- }
- .contextMenu {
- Button(role: .destructive) {
- draftsManager.remove(draft)
- } label: {
- Label("Delete Draft", systemImage: "trash")
- }
- }
- .onDrag {
- let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id)
- activity.displaysAuxiliaryScene = true
- return NSItemProvider(object: activity)
- }
- }
- .onDelete { indices in
- indices
- .map { visibleDrafts[$0] }
- .forEach { draftsManager.remove($0) }
- }
- .appGroupedListRowBackground()
- }
- .listStyle(.plain)
- .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self)
- .navigationTitle(Text("Drafts"))
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- uiState.isShowingDraftsList = false
- }
- }
- }
- }
- .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
- Button("Cancel", role: .cancel) {
- draftForDifferentReply = nil
- }
- Button("Restore Draft") {
- uiState.delegate?.selectDraft(draft)
- }
- } message: { draft in
- Text("The selected draft is a reply to a different post, do you wish to use it?")
- }
- }
-
- private func maybeSelectDraft(_ draft: Draft) {
- if draft.inReplyToID != currentDraft.inReplyToID,
- currentDraft.hasContent {
- draftForDifferentReply = draft
- } else {
- uiState.delegate?.selectDraft(draft)
- }
- }
-}
-
-struct DraftView: View {
- @ObservedObject private var draft: Draft
-
- init(draft: Draft) {
- self._draft = ObservedObject(wrappedValue: draft)
- }
-
- var body: some View {
- HStack {
- VStack(alignment: .leading) {
- if draft.contentWarningEnabled {
- Text(draft.contentWarning)
- .font(.body.bold())
- .foregroundColor(.secondary)
- }
-
- Text(draft.text)
- .font(.body)
-
- HStack(spacing: 8) {
- ForEach(draft.attachments) { attachment in
- ComposeAttachmentImage(attachment: attachment, fullSize: false)
- .frame(width: 50, height: 50)
- .cornerRadius(5)
- }
- }
- }
-
- Spacer()
-
- Text(draft.lastModified.timeAgoString())
- .font(.body)
- .foregroundColor(.secondary)
- }
- }
-}
-
-//struct DraftsView_Previews: PreviewProvider {
-// static var previews: some View {
-// DraftsView(currentDraft: Draft(accountID: ""))
-// }
-//}
diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift
deleted file mode 100644
index 2ba9dae4..00000000
--- a/Tusker/Screens/Compose/MainComposeTextView.swift
+++ /dev/null
@@ -1,443 +0,0 @@
-//
-// MainComposeTextView.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/29/20.
-// Copyright © 2020 Shadowfacts. All rights reserved.
-//
-
-import SwiftUI
-import Pachyderm
-
-struct MainComposeTextView: View, PlaceholderViewProvider {
- @ObservedObject var draft: Draft
- @State private var placeholder: PlaceholderView = Self.placeholderView()
-
- let minHeight: CGFloat = 150
- @State private var height: CGFloat?
- @Binding var becomeFirstResponder: Bool
- @State private var hasFirstAppeared = false
- @ScaledMetric private var fontSize = 20
- @Environment(\.colorScheme) private var colorScheme
-
- var body: some View {
- ZStack(alignment: .topLeading) {
- colorScheme == .dark ? Color.appFill : Color(uiColor: .secondarySystemBackground)
-
- if draft.text.isEmpty {
- placeholder
- .font(.system(size: fontSize))
- .foregroundColor(.secondary)
- .offset(x: 4, y: 8)
- .accessibilityHidden(true)
- }
-
- MainComposeWrappedTextView(
- text: $draft.text,
- visibility: draft.visibility,
- becomeFirstResponder: $becomeFirstResponder
- ) { (textView) in
- self.height = max(textView.contentSize.height, minHeight)
- }
- }
- .frame(height: height ?? minHeight)
- .onAppear {
- if !hasFirstAppeared {
- hasFirstAppeared = true
- becomeFirstResponder = true
- }
- }
- }
-
- @ViewBuilder
- static func placeholderView() -> some View {
- let components = Calendar.current.dateComponents([.month, .day], from: Date())
- if components.month == 3 && components.day == 14,
- Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
- Text("Happy π day!")
- } else if components.month == 4 && components.day == 1 {
- Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
- } else if components.month == 9 && components.day == 5 {
- // https://weirder.earth/@noracodes/109276419847254552
- // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
- Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
- } else if components.month == 9 && components.day == 21 {
- Text("Do you remember?")
- } else if components.month == 10 && components.day == 31 {
- if .random() {
- Text("Post something spooky!")
- } else {
- Text("Any questions?")
- }
- } else {
- Text("What's on your mind?")
- }
- }
-}
-
-// exists to provide access to the type alias since the @State property needs it to be explicit
-private protocol PlaceholderViewProvider {
- associatedtype PlaceholderView: View
- @ViewBuilder
- static func placeholderView() -> PlaceholderView
-}
-
-struct MainComposeWrappedTextView: UIViewRepresentable {
- typealias UIViewType = UITextView
-
- @Binding var text: String
- let visibility: Status.Visibility
- @Binding var becomeFirstResponder: Bool
- var textDidChange: (UITextView) -> Void
-
- @EnvironmentObject var uiState: ComposeUIState
- @EnvironmentObject var mastodonController: MastodonController
- @ObservedObject var preferences = Preferences.shared
- @Environment(\.isEnabled) var isEnabled: Bool
-
- func makeUIView(context: Context) -> UITextView {
- let textView = WrappedTextView(uiState: uiState)
- textView.delegate = context.coordinator
- textView.isEditable = true
- textView.backgroundColor = .clear
- textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 20))
- textView.adjustsFontForContentSizeCategory = true
- textView.textContainer.lineBreakMode = .byWordWrapping
- context.coordinator.textView = textView
- return textView
- }
-
- func updateUIView(_ uiView: UITextView, context: Context) {
- if context.coordinator.skipSettingTextOnNextUpdate {
- context.coordinator.skipSettingTextOnNextUpdate = false
- } else {
- context.coordinator.skipNextAutocompleteUpdate = true
- uiView.text = text
- }
-
- uiView.isEditable = isEnabled
- uiView.keyboardType = preferences.useTwitterKeyboard ? .twitter : .default
-
- context.coordinator.text = $text
- context.coordinator.didChange = textDidChange
- context.coordinator.uiState = uiState
-
- // 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 {
- self.textDidChange(uiView)
-
- if becomeFirstResponder {
- // calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
- uiView.becomeFirstResponder()
- // can't update @State vars during the SwiftUI update
- becomeFirstResponder = false
- }
- }
- }
-
- func makeCoordinator() -> Coordinator {
- return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
- }
-
- class WrappedTextView: UITextView {
- private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
- unowned var uiState: ComposeUIState
-
- init(uiState: ComposeUIState) {
- self.uiState = uiState
- super.init(frame: .zero, textContainer: nil)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
- if formattingActions.contains(action) {
- return Preferences.shared.statusContentType != .plain
- }
-
- return super.canPerformAction(action, withSender: sender)
- }
-
- override func toggleBoldface(_ sender: Any?) {
- (delegate as! Coordinator).applyFormat(.bold)
- }
-
- override func toggleItalics(_ sender: Any?) {
- (delegate as! Coordinator).applyFormat(.italics)
- }
-
- override func validate(_ command: UICommand) {
- super.validate(command)
-
- if formattingActions.contains(command.action),
- Preferences.shared.statusContentType != .plain {
- command.attributes.remove(.disabled)
- }
- }
-
- override func paste(_ sender: Any?) {
- // we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion
- // and things like URLs end up pasting as attachments
- if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) {
- uiState.delegate?.paste(itemProviders: UIPasteboard.general.itemProviders)
- } else {
- super.paste(sender)
- }
- }
-
- }
-
- class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {
- weak var textView: UITextView?
- var text: Binding
- var didChange: (UITextView) -> Void
- // break retained cycle through ComposeUIState.currentInput
- unowned var uiState: ComposeUIState
- var caretScrollPositionAnimator: UIViewPropertyAnimator?
-
- var skipSettingTextOnNextUpdate = false
- var skipNextAutocompleteUpdate = false
-
- var toolbarElements: [ComposeUIState.ToolbarElement] {
- [.emojiPicker, .formattingButtons]
- }
-
- init(text: Binding, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
- self.text = text
- self.didChange = didChange
- self.uiState = uiState
-
- super.init()
-
- NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
- }
-
- @objc private func keyboardDidShow() {
- guard let textView,
- textView.isFirstResponder else { return }
- ensureCursorVisible(textView: textView)
- }
-
- func textViewDidChange(_ textView: UITextView) {
- text.wrappedValue = textView.text
- didChange(textView)
-
- ensureCursorVisible(textView: textView)
- }
-
- func applyFormat(_ format: StatusFormat) {
- guard let textView = textView,
- textView.isFirstResponder,
- let insertionResult = format.insertionResult else {
- return
- }
-
- let currentSelectedRange = textView.selectedRange
- if currentSelectedRange.length == 0 {
- textView.insertText(insertionResult.prefix + insertionResult.suffix)
- textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
- } else {
- let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound)
- let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound)
- let selectedText = textView.text[start.. UIMenu? {
- var actions = suggestedActions
- if Preferences.shared.statusContentType != .plain,
- let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
- if range.length > 0 {
- let formatMenu = suggestedActions[index] as! UIMenu
- let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
- var image: UIImage?
- if let imageName = fmt.imageName {
- image = UIImage(systemName: imageName)
- }
- return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
- self?.applyFormat(fmt)
- }
- })
- actions[index] = newFormatMenu
- } else {
- actions.remove(at: index)
- }
- }
- if range.length == 0 {
- actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
- self?.uiState.shouldEmojiAutocompletionBeginExpanded = true
- self?.beginAutocompletingEmoji()
- }))
- }
- return UIMenu(children: actions)
- }
-
- func beginAutocompletingEmoji() {
- guard let textView = textView else {
- return
- }
- var insertSpace = false
- if let text = textView.text,
- textView.selectedRange.upperBound > 0 {
- let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound))
- insertSpace = !text[characterBeforeCursorIndex].isWhitespace
- }
- textView.insertText((insertSpace ? " " : "") + ":")
- }
-
- func autocomplete(with string: String) {
- guard let textView = textView,
- let text = textView.text,
- let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
- return
- }
-
- let distanceToEnd = text.utf16.count - textView.selectedRange.upperBound
-
- let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
-
- let insertSpace: Bool
- if distanceToEnd > 0 {
- let charAfterCursor = text[characterBeforeCursorIndex]
- insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
- } else {
- insertSpace = true
- }
- let string = insertSpace ? string + " " : string
-
- textView.text.replaceSubrange(lastWordStartIndex.. text.startIndex {
- // if the character before the "word" beginning is a valid part of a "word",
- // we aren't able to autocomplete
- let c = text[text.index(before: lastWordStartIndex)]
- if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
- uiState.autocompleteState = nil
- return
- }
- }
-
- let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
-
- if lastWordStartIndex >= text.startIndex {
- let lastWord = text[lastWordStartIndex.. Bool {
- return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
- }
-
- private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
- guard let textView = textView,
- textView.isFirstResponder,
- textView.selectedRange.length == 0,
- textView.selectedRange.upperBound > 0,
- let text = textView.text,
- text.count > 0 else {
- return nil
- }
-
- let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
-
- var lastWordStartIndex = text.index(before: characterBeforeCursorIndex)
- var foundFirstAtSign = false
- while true {
- let c = text[lastWordStartIndex]
-
- if !isPermittedForAutocomplete(c) {
- if foundFirstAtSign {
- if c != "@" {
- // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
- lastWordStartIndex = text.index(after: lastWordStartIndex)
- }
- break
- } else {
- if c == "@" {
- foundFirstAtSign = true
- } else if c != "." {
- // periods are allowed for domain names in mentions
- break
- }
- }
- }
-
- if lastWordStartIndex > text.startIndex {
- lastWordStartIndex = text.index(before: lastWordStartIndex)
- } else {
- break
- }
- }
-
- return (lastWordStartIndex, foundFirstAtSign)
- }
- }
-}
diff --git a/Tusker/Screens/Customize Timelines/EditFilterView.swift b/Tusker/Screens/Customize Timelines/EditFilterView.swift
index 12e3972f..cd2e5820 100644
--- a/Tusker/Screens/Customize Timelines/EditFilterView.swift
+++ b/Tusker/Screens/Customize Timelines/EditFilterView.swift
@@ -8,6 +8,7 @@
import SwiftUI
import Pachyderm
+import TuskerComponents
struct EditFilterView: View {
private static let expiresInOptions: [MenuPicker.Option] = {
@@ -214,6 +215,18 @@ private struct FilterContextToggleStyle: ToggleStyle {
}
}
+private extension View {
+ @available(iOS, obsoleted: 16.0)
+ @ViewBuilder
+ func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
+ if #available(iOS 16.0, *) {
+ self.scrollDismissesKeyboard(.interactively)
+ } else {
+ self
+ }
+ }
+}
+
//struct EditFilterView_Previews: PreviewProvider {
// static var previews: some View {
// EditFilterView()
diff --git a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift
index 408e10ce..0104cfab 100644
--- a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift
+++ b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift
@@ -7,6 +7,7 @@
//
import UIKit
+import UserAccounts
protocol FastAccountSwitcherViewControllerDelegate: AnyObject {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController)
@@ -139,9 +140,9 @@ class FastAccountSwitcherViewController: UIViewController {
addAccountPlaceholder
]
- for account in LocalData.shared.accounts {
+ for account in UserAccountsManager.shared.accounts {
let accountView = FastSwitchingAccountView(account: account, orientation: itemOrientation)
- accountView.isCurrent = account.id == LocalData.shared.mostRecentAccountID
+ accountView.isCurrent = account.id == UserAccountsManager.shared.mostRecentAccountID
accountsStack.addArrangedSubview(accountView)
accountViews.append(accountView)
}
@@ -168,9 +169,9 @@ class FastAccountSwitcherViewController: UIViewController {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount()
}
} else {
- let account = LocalData.shared.accounts[newIndex - 1]
+ let account = UserAccountsManager.shared.accounts[newIndex - 1]
- if account.id != LocalData.shared.mostRecentAccountID {
+ if account.id != UserAccountsManager.shared.mostRecentAccountID {
if hapticFeedback {
selectionChangedFeedbackGenerator?.selectionChanged()
}
diff --git a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift
index e5930cf7..7e04fbc3 100644
--- a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift
+++ b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift
@@ -7,6 +7,7 @@
//
import UIKit
+import UserAccounts
class FastSwitchingAccountView: UIView {
@@ -49,7 +50,7 @@ class FastSwitchingAccountView: UIView {
private var avatarRequest: ImageCache.Request?
- init(account: LocalData.UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) {
+ init(account: UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) {
self.orientation = orientation
super.init(frame: .zero)
commonInit()
@@ -121,7 +122,7 @@ class FastSwitchingAccountView: UIView {
isAccessibilityElement = true
}
- private func setupAccount(account: LocalData.UserAccountInfo) {
+ private func setupAccount(account: UserAccountInfo) {
usernameLabel.text = account.username
instanceLabel.text = account.instanceURL.host!
let controller = MastodonController.getForAccount(account)
diff --git a/Tusker/Screens/Large Image/LargeImageContentView.swift b/Tusker/Screens/Large Image/LargeImageContentView.swift
index 197d2bc8..f36515ce 100644
--- a/Tusker/Screens/Large Image/LargeImageContentView.swift
+++ b/Tusker/Screens/Large Image/LargeImageContentView.swift
@@ -10,6 +10,7 @@ import UIKit
import Pachyderm
@preconcurrency import AVFoundation
@preconcurrency import VisionKit
+import TuskerComponents
protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get }
diff --git a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift
index 0aa8397a..86da2597 100644
--- a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift
+++ b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift
@@ -7,6 +7,7 @@
import UIKit
import Pachyderm
+import TuskerComponents
class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableViewController {
diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift
index f59689c8..20d0a793 100644
--- a/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift
+++ b/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift
@@ -7,6 +7,7 @@
//
import UIKit
+import TuskerComponents
protocol LargeImageAnimatableViewController: UIViewController {
var animationSourceView: UIImageView? { get }
diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift
index dab88290..e7366beb 100644
--- a/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift
+++ b/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift
@@ -7,6 +7,7 @@
//
import UIKit
+import TuskerComponents
class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift
index eb35c7ea..1b325867 100644
--- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift
+++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift
@@ -8,6 +8,8 @@
import UIKit
import ScreenCorners
+import UserAccounts
+import ComposeUI
class AccountSwitchingContainerViewController: UIViewController {
@@ -16,7 +18,7 @@ class AccountSwitchingContainerViewController: UIViewController {
private var userActivities: [String: NSUserActivity] = [:]
- init(root: TuskerRootViewController, for account: LocalData.UserAccountInfo) {
+ init(root: TuskerRootViewController, for account: UserAccountInfo) {
self.currentAccountID = account.id
self.root = root
@@ -33,7 +35,7 @@ class AccountSwitchingContainerViewController: UIViewController {
embedChild(root)
}
- func setRoot(_ newRoot: TuskerRootViewController, for account: LocalData.UserAccountInfo, animating direction: AnimationDirection) {
+ func setRoot(_ newRoot: TuskerRootViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
let oldRoot = self.root
if direction == .none {
oldRoot.removeViewAndController()
diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift
index d740c9d5..b349f104 100644
--- a/Tusker/Screens/Main/Duckable+Root.swift
+++ b/Tusker/Screens/Main/Duckable+Root.swift
@@ -8,14 +8,15 @@
import UIKit
import Duckable
+import ComposeUI
@available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
if let compose = duckedViewController as? ComposeHostingController,
- compose.draft.hasContent {
- activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft)
+ compose.controller.draft.hasContent {
+ activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft)
}
return activity
}
diff --git a/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift b/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift
index dd7e5961..0de63d85 100644
--- a/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift
+++ b/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift
@@ -7,6 +7,7 @@
//
import UIKit
+import UserAccounts
class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
@@ -35,7 +36,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
fatalError("init(coder:) has not been implemented")
}
- func updateUI(item: MainSidebarViewController.Item, account: LocalData.UserAccountInfo) async {
+ func updateUI(item: MainSidebarViewController.Item, account: UserAccountInfo) async {
var config = defaultContentConfiguration()
config.text = item.title
config.image = UIImage(systemName: item.imageName!)
diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift
index a6a950c4..6fb73312 100644
--- a/Tusker/Screens/Main/MainTabBarViewController.swift
+++ b/Tusker/Screens/Main/MainTabBarViewController.swift
@@ -7,6 +7,7 @@
//
import UIKit
+import ComposeUI
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
@@ -223,7 +224,7 @@ extension MainTabBarViewController {
case .notifications:
return NotificationsPageViewController(mastodonController: mastodonController)
case .compose:
- return ComposeHostingController(mastodonController: mastodonController)
+ return ComposeHostingController(draft: nil, mastodonController: mastodonController)
case .explore:
return ExploreViewController(mastodonController: mastodonController)
case .myProfile:
@@ -274,7 +275,8 @@ extension MainTabBarViewController: StateRestorableViewController {
var activity: NSUserActivity?
if let presentedNav = presentedViewController as? UINavigationController,
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
- activity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: compose.draft.accountID)
+ let draft = compose.controller.draft
+ activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID)
} else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController {
activity = vc.stateRestorationActivity()
}
diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift
index 861e664d..38c9171b 100644
--- a/Tusker/Screens/Main/TuskerRootViewController.swift
+++ b/Tusker/Screens/Main/TuskerRootViewController.swift
@@ -7,6 +7,7 @@
//
import UIKit
+import ComposeUI
@MainActor
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
diff --git a/Tusker/Screens/Mute/MuteAccountView.swift b/Tusker/Screens/Mute/MuteAccountView.swift
index af33fb12..954f1711 100644
--- a/Tusker/Screens/Mute/MuteAccountView.swift
+++ b/Tusker/Screens/Mute/MuteAccountView.swift
@@ -8,6 +8,7 @@
import SwiftUI
import Pachyderm
+import TuskerComponents
struct MuteAccountView: View {
private static let durationOptions: [MenuPicker.Option] = {
@@ -56,9 +57,12 @@ struct MuteAccountView: View {
Form {
Section {
HStack {
- ComposeAvatarImageView(url: account.avatar)
- .frame(width: 50, height: 50)
- .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
+ AvatarImageView(
+ url: account.avatar,
+ size: 50,
+ style: Preferences.shared.avatarStyle == .circle ? .circle : .roundRect,
+ fetchAvatar: { await ImageCache.avatars.get($0).1 }
+ )
VStack(alignment: .leading) {
AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17)
diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift
index f3331181..402c783c 100644
--- a/Tusker/Screens/Onboarding/OnboardingViewController.swift
+++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift
@@ -10,10 +10,11 @@ import UIKit
import AuthenticationServices
import Pachyderm
import OSLog
+import UserAccounts
protocol OnboardingViewControllerDelegate {
@MainActor
- func didFinishOnboarding(account: LocalData.UserAccountInfo)
+ func didFinishOnboarding(account: UserAccountInfo)
}
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "OnboardingViewController")
@@ -145,7 +146,7 @@ class OnboardingViewController: UINavigationController {
}
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account
- let tempAccountInfo = LocalData.UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken)
+ let tempAccountInfo = UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
updateStatus("Checking Credentials")
@@ -158,7 +159,7 @@ class OnboardingViewController: UINavigationController {
throw Error.gettingOwnAccount(error)
}
- let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
+ let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
diff --git a/Tusker/Screens/Preferences/AdvancedPrefsView.swift b/Tusker/Screens/Preferences/AdvancedPrefsView.swift
index d5aed245..d698f14c 100644
--- a/Tusker/Screens/Preferences/AdvancedPrefsView.swift
+++ b/Tusker/Screens/Preferences/AdvancedPrefsView.swift
@@ -9,6 +9,7 @@ import SwiftUI
import Pachyderm
import CoreData
import CloudKit
+import UserAccounts
struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared
@@ -30,7 +31,7 @@ struct AdvancedPrefsView : View {
var formattingFooter: some View {
var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch).\n"
- if let account = LocalData.shared.getMostRecentAccount() {
+ if let account = UserAccountsManager.shared.getMostRecentAccount() {
let mastodonController = MastodonController.getForAccount(account)
// shouldn't need to load the instance here, because loading it is kicked off my the scene delegate
if !mastodonController.instanceFeatures.probablySupportsMarkdown {
@@ -135,7 +136,7 @@ struct AdvancedPrefsView : View {
].map {
$0.getDiskSizeInBytes() ?? 0
}.reduce(0, +)
- mastodonCacheSize = LocalData.shared.accounts.map {
+ mastodonCacheSize = UserAccountsManager.shared.accounts.map {
let descriptions = MastodonController.getForAccount($0).persistentContainer.persistentStoreDescriptions
return descriptions.map {
guard let url = $0.url else {
@@ -148,7 +149,7 @@ struct AdvancedPrefsView : View {
}
private func clearCache() {
- for account in LocalData.shared.accounts {
+ for account in UserAccountsManager.shared.accounts {
let controller = MastodonController.getForAccount(account)
let container = controller.persistentContainer
do {
@@ -178,7 +179,7 @@ struct AdvancedPrefsView : View {
}
private func resetUI() {
- let mostRecent = LocalData.shared.getMostRecentAccount()!
+ let mostRecent = UserAccountsManager.shared.getMostRecentAccount()!
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": mostRecent])
}
}
diff --git a/Tusker/Screens/Preferences/ComposingPrefsView.swift b/Tusker/Screens/Preferences/ComposingPrefsView.swift
index d1e91fda..59635f5d 100644
--- a/Tusker/Screens/Preferences/ComposingPrefsView.swift
+++ b/Tusker/Screens/Preferences/ComposingPrefsView.swift
@@ -27,7 +27,7 @@ struct ComposingPrefsView: View {
var visibilitySection: some View {
Section {
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) {
- ForEach(Status.Visibility.allCases, id: \.self) { visibility in
+ ForEach(Visibility.allCases, id: \.self) { visibility in
HStack {
Image(systemName: visibility.imageName)
Text(visibility.displayName)
diff --git a/Tusker/Screens/Preferences/LocalAccountAvatarView.swift b/Tusker/Screens/Preferences/LocalAccountAvatarView.swift
index 6d17804e..b586e528 100644
--- a/Tusker/Screens/Preferences/LocalAccountAvatarView.swift
+++ b/Tusker/Screens/Preferences/LocalAccountAvatarView.swift
@@ -7,9 +7,10 @@
//
import SwiftUI
+import UserAccounts
struct LocalAccountAvatarView: View {
- let localAccountInfo: LocalData.UserAccountInfo
+ let localAccountInfo: UserAccountInfo
@State var avatarImage: UIImage? = nil
@ObservedObject var preferences = Preferences.shared
diff --git a/Tusker/Screens/Preferences/PreferencesNavigationController.swift b/Tusker/Screens/Preferences/PreferencesNavigationController.swift
index 5514a635..a36aee6d 100644
--- a/Tusker/Screens/Preferences/PreferencesNavigationController.swift
+++ b/Tusker/Screens/Preferences/PreferencesNavigationController.swift
@@ -8,6 +8,7 @@
import UIKit
import SwiftUI
+import UserAccounts
class PreferencesNavigationController: UINavigationController {
@@ -64,7 +65,7 @@ class PreferencesNavigationController: UINavigationController {
guard let windowScene = self.view.window?.windowScene else {
return
}
- let account = notification.userInfo!["account"] as! LocalData.UserAccountInfo
+ let account = notification.userInfo!["account"] as! UserAccountInfo
if let sceneDelegate = windowScene.delegate as? MainSceneDelegate {
isSwitchingAccounts = true
dismiss(animated: true) { // dismiss preferences
@@ -85,8 +86,8 @@ class PreferencesNavigationController: UINavigationController {
sceneDelegate.logoutCurrent()
}
} else {
- LogoutService(accountInfo: LocalData.shared.getMostRecentAccount()!).run()
- let accountID = LocalData.shared.getMostRecentAccount()?.id
+ LogoutService(accountInfo: UserAccountsManager.shared.getMostRecentAccount()!).run()
+ let accountID = UserAccountsManager.shared.getMostRecentAccount()?.id
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: UserActivityManager.mainSceneActivity(accountID: accountID), options: nil)
UIApplication.shared.requestSceneSessionDestruction(windowScene.session, options: nil)
}
@@ -95,7 +96,7 @@ class PreferencesNavigationController: UINavigationController {
}
extension PreferencesNavigationController: OnboardingViewControllerDelegate {
- func didFinishOnboarding(account: LocalData.UserAccountInfo) {
+ func didFinishOnboarding(account: UserAccountInfo) {
guard let windowScene = self.view.window?.windowScene else {
return
}
diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift
index 049002e8..6ee2f61a 100644
--- a/Tusker/Screens/Preferences/PreferencesView.swift
+++ b/Tusker/Screens/Preferences/PreferencesView.swift
@@ -6,11 +6,12 @@
//
import SwiftUI
+import UserAccounts
struct PreferencesView: View {
let mastodonController: MastodonController
- @ObservedObject private var localData = LocalData.shared
+ @ObservedObject private var userAccounts = UserAccountsManager.shared
@State private var showingLogoutConfirmation = false
init(mastodonController: MastodonController) {
@@ -31,7 +32,7 @@ struct PreferencesView: View {
private var accountsSection: some View {
Section {
- ForEach(localData.accounts, id: \.accessToken) { (account) in
+ ForEach(userAccounts.accounts, id: \.accessToken) { (account) in
Button(action: {
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account])
}) {
@@ -58,12 +59,12 @@ struct PreferencesView: View {
}.onDelete { (indices: IndexSet) in
var indices = indices
var logoutFromCurrent = false
- if let index = indices.first(where: { localData.accounts[$0] == mastodonController.accountInfo! }) {
+ if let index = indices.first(where: { userAccounts.accounts[$0] == mastodonController.accountInfo! }) {
logoutFromCurrent = true
indices.remove(index)
}
- indices.forEach { LogoutService(accountInfo: localData.accounts[$0]).run() }
+ indices.forEach { LogoutService(accountInfo: userAccounts.accounts[$0]).run() }
if logoutFromCurrent {
self.logoutPressed()
diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift
index bdfd765b..2579372a 100644
--- a/Tusker/Screens/Profile/ProfileViewController.swift
+++ b/Tusker/Screens/Profile/ProfileViewController.swift
@@ -78,7 +78,7 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
composeButton.menu = UIMenu(children: [
- UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
+ UIAction(title: "Direct Message", image: UIImage(systemName: Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
self.composeDirectMentioning()
})
])
diff --git a/Tusker/Screens/Report/ReportView.swift b/Tusker/Screens/Report/ReportView.swift
index 5b85a79c..14b5ede9 100644
--- a/Tusker/Screens/Report/ReportView.swift
+++ b/Tusker/Screens/Report/ReportView.swift
@@ -7,6 +7,7 @@
//
import SwiftUI
+import TuskerComponents
struct ReportView: View {
@@ -46,10 +47,13 @@ struct ReportView: View {
Form {
Section {
HStack {
- ComposeAvatarImageView(url: account.avatar)
- .frame(width: 50, height: 50)
- .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
-
+ AvatarImageView(
+ url: account.avatar,
+ size: 50,
+ style: Preferences.shared.avatarStyle == .circle ? .circle : .roundRect,
+ fetchAvatar: { await ImageCache.avatars.get($0).1 }
+ )
+
VStack(alignment: .leading) {
AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17)
Text("@\(account.acct)")
@@ -97,9 +101,19 @@ struct ReportView: View {
.appGroupedListRowBackground()
Section {
- ComposeTextView(text: $report.comment, placeholder: Text("Add any additional comments"))
- .backgroundColor(.clear)
- .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
+ ZStack(alignment: .topLeading) {
+ if report.comment.isEmpty {
+ Text("Add any additional comments")
+ .offset(x: 4, y: 8)
+ .foregroundColor(.secondary)
+ }
+
+ TextEditor(text: $report.comment)
+ .background(.clear)
+ .frame(minHeight: 100)
+ }
+ .font(.body)
+ .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
}
.appGroupedListRowBackground()
diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift
index 1ecca0e5..b72d555f 100644
--- a/Tusker/Screens/Utilities/Previewing.swift
+++ b/Tusker/Screens/Utilities/Previewing.swift
@@ -358,16 +358,16 @@ extension MenuActionProvider {
}),
createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in
guard let self = self else { return }
- let draft = self.mastodonController!.createDraft()
+
+ var text = ""
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
if !title.isEmpty {
- draft.text += title
- draft.text += ":\n"
+ text += title
+ text += ":\n"
}
- draft.text += url.absoluteString
- // prevents the draft from being saved automatically until the user makes a change
- // also prevents it from being posted without being changed
- draft.initialText = draft.text
+ text += url.absoluteString
+
+ let draft = self.mastodonController!.createDraft(text: text)
self.navigationDelegate?.compose(editing: draft)
})
]
diff --git a/Tusker/Screens/Utilities/UIAlertController+Visibility.swift b/Tusker/Screens/Utilities/UIAlertController+Visibility.swift
index 6f4d8040..ad5c27aa 100644
--- a/Tusker/Screens/Utilities/UIAlertController+Visibility.swift
+++ b/Tusker/Screens/Utilities/UIAlertController+Visibility.swift
@@ -11,10 +11,10 @@ import Pachyderm
extension UIAlertController {
- convenience init(currentVisibility: Status.Visibility?, completion: @escaping (Status.Visibility?) -> Void) {
+ convenience init(currentVisibility: Visibility?, completion: @escaping (Visibility?) -> Void) {
self.init(title: "Post Visibility", message: nil, preferredStyle: .actionSheet)
- for visibility in Status.Visibility.allCases {
+ for visibility in Visibility.allCases {
let action = UIAlertAction(title: visibility.displayName, style: .default) { (_) in
completion(visibility)
}
diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift
index 9931d962..4bf6e663 100644
--- a/Tusker/Shortcuts/UserActivityHandlingContext.swift
+++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift
@@ -8,6 +8,7 @@
import UIKit
import Duckable
+import ComposeUI
@MainActor
protocol UserActivityHandlingContext {
diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift
index 48fd88f1..d4c82ad5 100644
--- a/Tusker/Shortcuts/UserActivityManager.swift
+++ b/Tusker/Shortcuts/UserActivityManager.swift
@@ -10,6 +10,8 @@ import UIKit
import Intents
import Pachyderm
import OSLog
+import UserAccounts
+import ComposeUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
@@ -32,11 +34,11 @@ class UserActivityManager {
scene.session.mastodonController!
}
- static func getAccount(from activity: NSUserActivity) -> LocalData.UserAccountInfo? {
+ static func getAccount(from activity: NSUserActivity) -> UserAccountInfo? {
guard let id = activity.userInfo?["accountID"] as? String else {
return nil
}
- return LocalData.shared.getAccount(id: id)
+ return UserAccountsManager.shared.getAccount(id: id)
}
// MARK: - Main Scene
diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift
index 4e0a2172..f2f86b1c 100644
--- a/Tusker/TuskerNavigationDelegate.swift
+++ b/Tusker/TuskerNavigationDelegate.swift
@@ -9,6 +9,7 @@
import UIKit
import SafariServices
import Pachyderm
+import ComposeUI
@MainActor
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
@@ -102,11 +103,21 @@ extension TuskerNavigationDelegate {
presentDuckable(compose, animated: animated, isDucked: isDucked) {
return
} else {
- let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose)
- nav.presentationController?.delegate = compose
+ // TODO: is this still necessary?
+// nav.presentationController?.delegate = compose
present(nav, animated: animated)
}
+// let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
+// if #available(iOS 16.0, *),
+// presentDuckable(compose, animated: animated, isDucked: isDucked) {
+// return
+// } else {
+// let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
+// let nav = UINavigationController(rootViewController: compose)
+// nav.presentationController?.delegate = compose
+// present(nav, animated: animated)
+// }
}
}
diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift
index ec81c0aa..75e727a2 100644
--- a/Tusker/Views/Attachments/AttachmentView.swift
+++ b/Tusker/Views/Attachments/AttachmentView.swift
@@ -9,6 +9,7 @@
import UIKit
import Pachyderm
import AVFoundation
+import TuskerComponents
protocol AttachmentViewDelegate: AnyObject {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController?
diff --git a/Tusker/Views/StatusContentTextView.swift b/Tusker/Views/StatusContentTextView.swift
index ceb1ca45..05f3677b 100644
--- a/Tusker/Views/StatusContentTextView.swift
+++ b/Tusker/Views/StatusContentTextView.swift
@@ -14,7 +14,7 @@ class StatusContentTextView: ContentTextView {
private var statusID: String?
- func setTextFrom(status: StatusMO, precomputed attributedText: NSAttributedString? = nil) {
+ func setTextFrom(status: some StatusProtocol, precomputed attributedText: NSAttributedString? = nil) {
statusID = status.id
if let attributedText {
self.attributedText = attributedText
diff --git a/TuskerTests/ArrayUniqueTests.swift b/TuskerTests/ArrayUniqueTests.swift
index 3d6cc23b..6755e590 100644
--- a/TuskerTests/ArrayUniqueTests.swift
+++ b/TuskerTests/ArrayUniqueTests.swift
@@ -19,6 +19,12 @@ final class ArrayUniqueTests: XCTestCase {
XCTAssertEqual([a, b].uniques(by: \.string), [a])
}
+ func testUniquesOrder() {
+ let a = Test(string: "a")
+ let b = Test(string: "b")
+ XCTAssertEqual([a, b].uniques(), [a, b])
+ }
+
class Test: NSObject {
let id = UUID()
let string: String
diff --git a/TuskerUITests/ComposeTests.swift b/TuskerUITests/ComposeTests.swift
index ef96222f..546fe031 100644
--- a/TuskerUITests/ComposeTests.swift
+++ b/TuskerUITests/ComposeTests.swift
@@ -34,28 +34,28 @@ class ComposeTests: TuskerUITests {
XCTAssertFalse(app.staticTexts["What's on your mind?"].exists, "placeholder does not exist")
}
- func testCharacterCounter() {
- XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500")
- let textView = app.textViews.firstMatch
-
- let fragments = [
- "Hello",
- "World",
- "@admin",
- "@admin@example.com",
- "https://foo.example.com/?bar=baz#qux",
- ]
-
- var remaining = 500
- for s in fragments {
- let length = CharacterCounter.count(text: s)
- // add 1 for newline
- remaining -= length + 1
-
- textView.typeText(s + "\n")
- XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)")
- }
- }
+// func testCharacterCounter() {
+// XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500")
+// let textView = app.textViews.firstMatch
+//
+// let fragments = [
+// "Hello",
+// "World",
+// "@admin",
+// "@admin@example.com",
+// "https://foo.example.com/?bar=baz#qux",
+// ]
+//
+// var remaining = 500
+// for s in fragments {
+// let length = CharacterCounter.count(text: s)
+// // add 1 for newline
+// remaining -= length + 1
+//
+// textView.typeText(s + "\n")
+// XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)")
+// }
+// }
// func testToolbarSwitching() {
// // text view is automatically focused, so unfocus