Extract compose UI into separate package
This commit is contained in:
parent
350e331eb2
commit
0746e12737
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -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
|
||||||
|
}
|
|
@ -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"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# ComposeUI
|
||||||
|
|
||||||
|
A description of this package.
|
|
@ -0,0 +1,139 @@
|
||||||
|
//
|
||||||
|
// PostService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/27/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
class PostService: ObservableObject {
|
||||||
|
private let mastodonController: ComposeMastodonContext
|
||||||
|
private let config: ComposeUIConfig
|
||||||
|
private let draft: Draft
|
||||||
|
let totalSteps: Int
|
||||||
|
|
||||||
|
@Published var currentStep = 1
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func post() async throws {
|
||||||
|
guard draft.hasContent else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// save before posting, so if a crash occurs during network request, the status won't be lost
|
||||||
|
DraftsManager.save()
|
||||||
|
|
||||||
|
let uploadedAttachments = try await uploadAttachments()
|
||||||
|
|
||||||
|
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil
|
||||||
|
let sensitive = contentWarning != nil
|
||||||
|
|
||||||
|
let request = Client.createStatus(
|
||||||
|
text: textForPosting(),
|
||||||
|
contentType: config.contentType,
|
||||||
|
inReplyTo: draft.inReplyToID,
|
||||||
|
media: uploadedAttachments,
|
||||||
|
sensitive: sensitive,
|
||||||
|
spoilerText: contentWarning,
|
||||||
|
visibility: draft.visibility,
|
||||||
|
language: nil,
|
||||||
|
pollOptions: draft.poll?.options.map(\.text),
|
||||||
|
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||||
|
pollMultiple: draft.poll?.multiple,
|
||||||
|
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
let (_, _) = try await mastodonController.run(request)
|
||||||
|
currentStep += 1
|
||||||
|
|
||||||
|
DraftsManager.shared.remove(self.draft)
|
||||||
|
DraftsManager.save()
|
||||||
|
} catch let error as Client.Error {
|
||||||
|
throw Error.posting(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAttachments() async throws -> [Attachment] {
|
||||||
|
var attachments: [Attachment] = []
|
||||||
|
attachments.reserveCapacity(draft.attachments.count)
|
||||||
|
for (index, attachment) in draft.attachments.enumerated() {
|
||||||
|
let data: Data
|
||||||
|
let utType: UTType
|
||||||
|
do {
|
||||||
|
(data, utType) = try await getData(for: attachment)
|
||||||
|
currentStep += 1
|
||||||
|
} catch let error as AttachmentData.Error {
|
||||||
|
throw Error.attachmentData(index: index, cause: error)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
|
||||||
|
attachments.append(uploaded)
|
||||||
|
currentStep += 1
|
||||||
|
} catch let error as Client.Error {
|
||||||
|
throw Error.attachmentUpload(index: index, cause: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
case let .success(res):
|
||||||
|
continuation.resume(returning: res)
|
||||||
|
case let .failure(error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
|
||||||
|
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
|
||||||
|
let req = Client.upload(attachment: formAttachment, description: description)
|
||||||
|
return try await mastodonController.run(req).0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textForPosting() -> String {
|
||||||
|
var text = draft.text
|
||||||
|
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
|
||||||
|
// which we want to strip out before actually posting the status
|
||||||
|
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
|
||||||
|
|
||||||
|
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
|
||||||
|
text += " 👁"
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Error: Swift.Error, LocalizedError {
|
||||||
|
case attachmentData(index: Int, cause: AttachmentData.Error)
|
||||||
|
case attachmentUpload(index: Int, cause: Client.Error)
|
||||||
|
case posting(Client.Error)
|
||||||
|
|
||||||
|
var localizedDescription: String {
|
||||||
|
switch self {
|
||||||
|
case let .attachmentData(index: index, cause: cause):
|
||||||
|
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||||
|
case let .attachmentUpload(index: index, cause: cause):
|
||||||
|
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||||
|
case let .posting(error):
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,24 +1,25 @@
|
||||||
//
|
//
|
||||||
// CharacterCounter.swift
|
// CharacterCounter.swift
|
||||||
// Pachyderm
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/29/18.
|
// Created by Shadowfacts on 9/29/18.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
public struct CharacterCounter {
|
public struct CharacterCounter {
|
||||||
|
|
||||||
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
private 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 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)
|
let mentionsRemoved = removeMentions(in: text)
|
||||||
var count = mentionsRemoved.count
|
var count = mentionsRemoved.count
|
||||||
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
||||||
count -= match.range.length
|
count -= match.range.length
|
||||||
count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
|
count += instanceFeatures.charsReservedPerURL
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
|
@ -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<AutocompleteState?>.Publisher { get }
|
||||||
|
|
||||||
|
func autocomplete(with string: String)
|
||||||
|
|
||||||
|
func applyFormat(_ format: StatusFormat)
|
||||||
|
|
||||||
|
func beginAutocompletingEmoji()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ToolbarElement {
|
||||||
|
case emojiPicker
|
||||||
|
case formattingButtons
|
||||||
|
}
|
|
@ -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<Result: Sendable>(_ request: Request<Result>) 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]
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
//
|
||||||
|
// ComposeUIConfig.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import PhotosUI
|
||||||
|
import PencilKit
|
||||||
|
|
||||||
|
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 = AvatarStyle.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 {
|
||||||
|
public enum AvatarStyle: Equatable {
|
||||||
|
case roundRect, circle
|
||||||
|
|
||||||
|
var cornerRadiusFraction: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .roundRect:
|
||||||
|
return 0.1
|
||||||
|
case .circle:
|
||||||
|
return 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<M: View, P: View>(@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Bool>, @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<V: View>: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Void, Never>?
|
||||||
|
|
||||||
|
@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<String>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Void, Never>?
|
||||||
|
|
||||||
|
@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<Void, any Error>?
|
||||||
|
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<String>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
//
|
||||||
|
// AutocompleteMentionsController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
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<Void, Never>?
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,379 @@
|
||||||
|
//
|
||||||
|
// ComposeController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
public final class ComposeController: ViewController {
|
||||||
|
public typealias FetchAvatar = (URL) async -> UIImage?
|
||||||
|
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: 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 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Bool>) {
|
||||||
|
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<DraftsView>
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIHostingController<DraftsController.DraftsView> {
|
||||||
|
return UIHostingController(rootView: DraftsView())
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIHostingController<DraftsController.DraftsView>, 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<T, V: View>(_ value: T?, modify: (Self, T) -> V) -> some View {
|
||||||
|
if let value {
|
||||||
|
modify(self, value)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Pachyderm.Visibility>.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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// DismissMode.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/7/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DismissMode {
|
||||||
|
case cancel, post
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
//
|
||||||
|
// Draft.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/18/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
public class Draft: Codable, Identifiable, ObservableObject {
|
||||||
|
public let id: UUID
|
||||||
|
var lastModified: Date
|
||||||
|
|
||||||
|
@Published public var accountID: String
|
||||||
|
@Published public var text: String
|
||||||
|
@Published public var contentWarningEnabled: Bool
|
||||||
|
@Published public var contentWarning: String
|
||||||
|
@Published public var attachments: [DraftAttachment]
|
||||||
|
@Published public var inReplyToID: String?
|
||||||
|
@Published public var visibility: Visibility
|
||||||
|
@Published public var poll: Poll?
|
||||||
|
@Published public var localOnly: Bool
|
||||||
|
|
||||||
|
var initialText: String
|
||||||
|
|
||||||
|
public var hasContent: Bool {
|
||||||
|
(!text.isEmpty && text != initialText) ||
|
||||||
|
(contentWarningEnabled && !contentWarning.isEmpty) ||
|
||||||
|
attachments.count > 0 ||
|
||||||
|
poll?.hasContent == true
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
accountID: String,
|
||||||
|
text: String,
|
||||||
|
contentWarning: String,
|
||||||
|
inReplyToID: String?,
|
||||||
|
visibility: Visibility,
|
||||||
|
localOnly: Bool
|
||||||
|
) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.lastModified = Date()
|
||||||
|
|
||||||
|
self.accountID = accountID
|
||||||
|
self.text = text
|
||||||
|
self.contentWarning = contentWarning
|
||||||
|
self.contentWarningEnabled = !contentWarning.isEmpty
|
||||||
|
self.attachments = []
|
||||||
|
self.inReplyToID = inReplyToID
|
||||||
|
self.visibility = visibility
|
||||||
|
self.localOnly = localOnly
|
||||||
|
|
||||||
|
self.initialText = text
|
||||||
|
}
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.id = try container.decode(UUID.self, forKey: .id)
|
||||||
|
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
|
||||||
|
|
||||||
|
self.accountID = try container.decode(String.self, forKey: .accountID)
|
||||||
|
self.text = try container.decode(String.self, forKey: .text)
|
||||||
|
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
|
||||||
|
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
|
||||||
|
self.attachments = try container.decode([DraftAttachment].self, forKey: .attachments)
|
||||||
|
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
|
||||||
|
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
|
||||||
|
self.poll = try container.decode(Poll?.self, forKey: .poll)
|
||||||
|
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
|
||||||
|
|
||||||
|
self.initialText = try container.decode(String.self, forKey: .initialText)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(lastModified, forKey: .lastModified)
|
||||||
|
|
||||||
|
try container.encode(accountID, forKey: .accountID)
|
||||||
|
try container.encode(text, forKey: .text)
|
||||||
|
try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled)
|
||||||
|
try container.encode(contentWarning, forKey: .contentWarning)
|
||||||
|
try container.encode(attachments, forKey: .attachments)
|
||||||
|
try container.encode(inReplyToID, forKey: .inReplyToID)
|
||||||
|
try container.encode(visibility, forKey: .visibility)
|
||||||
|
try container.encode(poll, forKey: .poll)
|
||||||
|
try container.encode(localOnly, forKey: .localOnly)
|
||||||
|
|
||||||
|
try container.encode(initialText, forKey: .initialText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Draft: Equatable {
|
||||||
|
public static func ==(lhs: Draft, rhs: Draft) -> Bool {
|
||||||
|
return lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Draft {
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case lastModified
|
||||||
|
|
||||||
|
case accountID
|
||||||
|
case text
|
||||||
|
case contentWarningEnabled
|
||||||
|
case contentWarning
|
||||||
|
case attachments
|
||||||
|
case inReplyToID
|
||||||
|
case visibility
|
||||||
|
case poll
|
||||||
|
case localOnly
|
||||||
|
|
||||||
|
case initialText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Draft {
|
||||||
|
public class Poll: Codable, ObservableObject {
|
||||||
|
@Published public var options: [Option]
|
||||||
|
@Published public var multiple: Bool
|
||||||
|
@Published public var duration: TimeInterval
|
||||||
|
|
||||||
|
var hasContent: Bool {
|
||||||
|
options.contains { !$0.text.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
self.options = [Option(""), Option("")]
|
||||||
|
self.multiple = false
|
||||||
|
self.duration = 24 * 60 * 60 // 1 day
|
||||||
|
}
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.options = try container.decode([Option].self, forKey: .options)
|
||||||
|
self.multiple = try container.decode(Bool.self, forKey: .multiple)
|
||||||
|
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(options, forKey: .options)
|
||||||
|
try container.encode(multiple, forKey: .multiple)
|
||||||
|
try container.encode(duration, forKey: .duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case options
|
||||||
|
case multiple
|
||||||
|
case duration
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Option: Identifiable, Codable, ObservableObject {
|
||||||
|
public let id = UUID()
|
||||||
|
@Published public var text: String
|
||||||
|
|
||||||
|
init(_ text: String) {
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
self.text = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
if let dict = try? container.decode([UUID: SafeDraft].self, forKey: .drafts) {
|
||||||
|
self.drafts = dict.compactMapValues { $0.draft }
|
||||||
|
} else if let array = try? container.decode([SafeDraft].self, forKey: .drafts) {
|
||||||
|
self.drafts = array.reduce(into: [:], { partialResult, safeDraft in
|
||||||
|
if let draft = safeDraft.draft {
|
||||||
|
partialResult[draft.id] = draft
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(drafts, forKey: .drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private var drafts: [UUID: Draft] = [:]
|
||||||
|
var sorted: [Draft] {
|
||||||
|
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
|
||||||
|
}
|
||||||
|
|
||||||
|
public func add(_ draft: Draft) {
|
||||||
|
drafts[draft.id] = draft
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove(_ draft: Draft) {
|
||||||
|
drafts.removeValue(forKey: draft.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getBy(id: UUID) -> Draft? {
|
||||||
|
return drafts[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case drafts
|
||||||
|
}
|
||||||
|
|
||||||
|
// a container that always succeeds at decoding
|
||||||
|
// so if a single draft can't be decoded, we don't lose all drafts
|
||||||
|
struct SafeDraft: Decodable {
|
||||||
|
let draft: Draft?
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
self.draft = try? container.decode(Draft.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// OptionalObservedObject.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/15/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@propertyWrapper
|
||||||
|
struct OptionalObservedObject<T: ObservableObject>: 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
//
|
||||||
|
// TextViewCaretScrolling.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol TextViewCaretScrolling: AnyObject {
|
||||||
|
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TextViewCaretScrolling {
|
||||||
|
func ensureCursorVisible(textView: UITextView) {
|
||||||
|
guard textView.isFirstResponder,
|
||||||
|
let range = textView.selectedTextRange,
|
||||||
|
let scrollView = findParentScrollView(of: textView) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We use a UIViewProperty animator to change the scroll view position so that we can store the currently
|
||||||
|
// running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations
|
||||||
|
// from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can
|
||||||
|
// happen if the user is pressing return and quickly creating many new lines.
|
||||||
|
|
||||||
|
if let existing = caretScrollPositionAnimator {
|
||||||
|
existing.stopAnimation(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursorRect = textView.caretRect(for: range.start)
|
||||||
|
var rectToMakeVisible = textView.convert(cursorRect, to: scrollView)
|
||||||
|
|
||||||
|
// expand the rect to be three times the cursor height centered on the cursor so that there's
|
||||||
|
// some space between the bottom of the line of text being edited and the top of the keyboard
|
||||||
|
rectToMakeVisible.origin.y -= cursorRect.height
|
||||||
|
rectToMakeVisible.size.height *= 3
|
||||||
|
|
||||||
|
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||||
|
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||||
|
}
|
||||||
|
self.caretScrollPositionAnimator = animator
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findParentScrollView(of view: UIView) -> UIScrollView? {
|
||||||
|
var current: UIView = view
|
||||||
|
while let superview = current.superview {
|
||||||
|
if let scrollView = superview as? UIScrollView,
|
||||||
|
scrollView.isScrollEnabled {
|
||||||
|
return scrollView
|
||||||
|
} else {
|
||||||
|
current = superview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -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..<characterBeforeCursorIndex]
|
||||||
|
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
|
||||||
|
|
||||||
|
// periods are only allowed in mentions in the domain part
|
||||||
|
if lastWord.contains(".") {
|
||||||
|
if lastWord.first == "@" && foundFirstAtSign && permittedModes.contains(.mentions) {
|
||||||
|
return .mention(String(exceptFirst))
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch lastWord.first {
|
||||||
|
case "@" where permittedModes.contains(.mentions):
|
||||||
|
return .mention(String(exceptFirst))
|
||||||
|
case ":" where permittedModes.contains(.emojis):
|
||||||
|
return .emoji(String(exceptFirst))
|
||||||
|
case "#" where permittedModes.contains(.hashtags):
|
||||||
|
return .hashtag(String(exceptFirst))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findAutocompleteLastWord() -> (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 == "_"
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Controller: ViewController>: 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
//
|
||||||
|
// AttachmentDescriptionTextView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/12/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AttachmentDescriptionTextView: View {
|
||||||
|
@Binding private var text: String
|
||||||
|
private let placeholder: Text?
|
||||||
|
private let minHeight: CGFloat
|
||||||
|
|
||||||
|
@State private var height: CGFloat?
|
||||||
|
|
||||||
|
init(text: Binding<String>, placeholder: Text?, minHeight: CGFloat) {
|
||||||
|
self._text = text
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.minHeight = minHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if text.isEmpty, let placeholder {
|
||||||
|
placeholder
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.offset(x: 4, y: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedTextView(
|
||||||
|
text: $text,
|
||||||
|
textDidChange: self.textDidChange,
|
||||||
|
font: .preferredFont(forTextStyle: .body)
|
||||||
|
)
|
||||||
|
.frame(height: height ?? minHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textDidChange(_ textView: UITextView) {
|
||||||
|
height = max(minHeight, textView.contentSize.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WrappedTextView: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UITextView
|
||||||
|
|
||||||
|
@Binding var text: String
|
||||||
|
let textDidChange: ((UITextView) -> Void)
|
||||||
|
let font: UIFont
|
||||||
|
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UITextView {
|
||||||
|
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) {
|
||||||
|
uiView.text = text
|
||||||
|
uiView.isEditable = isEnabled
|
||||||
|
context.coordinator.textView = uiView
|
||||||
|
context.coordinator.text = $text
|
||||||
|
context.coordinator.didChange = textDidChange
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(text: $text, didChange: textDidChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
||||||
|
weak var textView: UITextView?
|
||||||
|
var text: Binding<String>
|
||||||
|
var didChange: (UITextView) -> Void
|
||||||
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
|
init(text: Binding<String>, 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
|
||||||
|
}
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
text.wrappedValue = textView.text
|
||||||
|
didChange(textView)
|
||||||
|
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
//
|
||||||
|
// AttachmentThumbnailView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/10/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Photos
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
struct AttachmentThumbnailView: View {
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
let fullSize: Bool
|
||||||
|
|
||||||
|
@State private var gifData: Data? = nil
|
||||||
|
@State private var image: UIImage? = nil
|
||||||
|
@State private var imageContentMode: ContentMode = .fill
|
||||||
|
@State private var imageBackgroundColor: Color = .black
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let gifData {
|
||||||
|
GIFViewWrapper(gifData: gifData)
|
||||||
|
} else if let image {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: imageContentMode)
|
||||||
|
.background(imageBackgroundColor)
|
||||||
|
} else {
|
||||||
|
Image(systemName: placeholderImageName)
|
||||||
|
.onAppear(perform: self.loadImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderImageName: String {
|
||||||
|
switch colorScheme {
|
||||||
|
case .light:
|
||||||
|
return "photo"
|
||||||
|
case .dark:
|
||||||
|
return "photo.fill"
|
||||||
|
@unknown default:
|
||||||
|
return "photo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage() {
|
||||||
|
switch attachment.data {
|
||||||
|
case let .image(originalData, originalType: _):
|
||||||
|
self.image = UIImage(data: originalData)
|
||||||
|
case let .asset(asset):
|
||||||
|
let size: CGSize
|
||||||
|
if fullSize {
|
||||||
|
size = PHImageManagerMaximumSize
|
||||||
|
} else {
|
||||||
|
// currently only used as thumbnail in ComposeAttachmentRow
|
||||||
|
size = CGSize(width: 80, height: 80)
|
||||||
|
}
|
||||||
|
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
|
||||||
|
if isGIF {
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||||
|
if typeIdentifier == UTType.gif.identifier {
|
||||||
|
self.gifData = data
|
||||||
|
} else if let data {
|
||||||
|
let image = UIImage(data: data)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .video(url):
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
|
self.image = UIImage(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
case let .drawing(drawing):
|
||||||
|
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||||
|
imageContentMode = .fit
|
||||||
|
imageBackgroundColor = .white
|
||||||
|
case let .gif(data):
|
||||||
|
self.gifData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
controller.startAnimating()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
//
|
||||||
|
// AvatarImageView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AvatarImageView: View {
|
||||||
|
let url: URL?
|
||||||
|
let size: CGFloat
|
||||||
|
@State private var image: UIImage?
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
imageView
|
||||||
|
.resizable()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.cornerRadius(controller.config.avatarStyle.cornerRadiusFraction * size)
|
||||||
|
.task {
|
||||||
|
if let url {
|
||||||
|
image = await controller.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: controller.config.avatarStyle == .roundRect ? "person.crop.square" : "person.crop.circle")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// CurrentAccountView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
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)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Bool>?
|
||||||
|
let focusNextView: Binding<Bool>?
|
||||||
|
|
||||||
|
init(text: Binding<String>, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = 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<String>
|
||||||
|
var focusNextView: Binding<Bool>?
|
||||||
|
var maxLength: Int?
|
||||||
|
|
||||||
|
@Published var autocompleteState: AutocompleteState?
|
||||||
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
||||||
|
|
||||||
|
weak var textField: UITextField?
|
||||||
|
|
||||||
|
init(controller: ComposeController, text: Binding<String>, focusNextView: Binding<Bool>?, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String>
|
||||||
|
let textDidChange: (UITextView) -> Void
|
||||||
|
|
||||||
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
|
@Published var autocompleteState: AutocompleteState?
|
||||||
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
||||||
|
var skipNextSelectionChangedAutocompleteUpdate = false
|
||||||
|
|
||||||
|
init(controller: ComposeController, text: Binding<String>, 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..<end]
|
||||||
|
textView.insertText(insertionResult.prefix + String(Substring(selectedText)) + insertionResult.suffix)
|
||||||
|
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: currentSelectedRange.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginAutocompletingEmoji() {
|
||||||
|
guard let 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 ? " " : "") + ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAutocompleteState() {
|
||||||
|
guard let textView else {
|
||||||
|
autocompleteState = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
//
|
||||||
|
// ReplyStatusView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
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 {
|
||||||
|
controller.displayNameLabel(status.account, .body, 17)
|
||||||
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(status.account.acct)")
|
||||||
|
.font(.body.weight(.light))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height)
|
||||||
|
.onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in
|
||||||
|
displayNameHeight = newValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 {
|
||||||
|
contentHeight = newHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: contentHeight ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 50, alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
||||||
|
// using a coordinate space declared outside of the List doesn't work, so we do the math ourselves
|
||||||
|
let globalFrame = geometry.frame(in: .global)
|
||||||
|
let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY)
|
||||||
|
|
||||||
|
// add rowTopInset so that the image is always at least rowTopInset away from the top
|
||||||
|
var offset = scrollOffset + rowTopInset
|
||||||
|
|
||||||
|
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
|
||||||
|
offset = max(offset, 0)
|
||||||
|
|
||||||
|
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
|
||||||
|
let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0)
|
||||||
|
|
||||||
|
// 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 AvatarImageView(url: status.account.avatar, size: 50)
|
||||||
|
.offset(x: 0, y: offset)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DisplayNameHeightPrefKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat = 0
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -19,6 +19,9 @@ public class InstanceFeatures: ObservableObject {
|
||||||
|
|
||||||
@Published private var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
@Published private var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
||||||
@Published public private(set) var maxStatusChars = 500
|
@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 {
|
public var localOnlyPosts: Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
|
@ -155,6 +158,11 @@ public class InstanceFeatures: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
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
|
||||||
|
}
|
||||||
|
|
||||||
_featuresUpdated.send()
|
_featuresUpdated.send()
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,6 +64,6 @@ extension Hashtag: Equatable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(url)
|
hasher.combine(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public protocol AccountProtocol {
|
public protocol AccountProtocol {
|
||||||
associatedtype Account: AccountProtocol
|
|
||||||
|
|
||||||
var id: String { get }
|
var id: String { get }
|
||||||
var username: String { get }
|
var username: String { get }
|
||||||
|
@ -27,7 +26,7 @@ public protocol AccountProtocol {
|
||||||
var moved: Bool? { get }
|
var moved: Bool? { get }
|
||||||
var bot: Bool? { get }
|
var bot: Bool? { get }
|
||||||
|
|
||||||
var movedTo: Account? { get }
|
var movedTo: Self? { get }
|
||||||
var emojis: [Emoji] { get }
|
var emojis: [Emoji] { get }
|
||||||
var fields: [Pachyderm.Account.Field] { get }
|
var fields: [Pachyderm.Account.Field] { get }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
}
|
|
@ -8,8 +8,8 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Relationship: Decodable, Sendable {
|
public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
||||||
public let id: String
|
public let accountID: String
|
||||||
public let following: Bool
|
public let following: Bool
|
||||||
public let followedBy: Bool
|
public let followedBy: Bool
|
||||||
public let blocking: Bool
|
public let blocking: Bool
|
||||||
|
@ -18,7 +18,21 @@ public struct Relationship: Decodable, Sendable {
|
||||||
public let followRequested: Bool
|
public let followRequested: Bool
|
||||||
public let domainBlocking: Bool
|
public let domainBlocking: Bool
|
||||||
public let showingReblogs: 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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
|
|
|
@ -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<Calendar.Component>([.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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
//
|
//
|
||||||
// AlertWithData.swift
|
// AlertWithData.swift
|
||||||
// Tusker
|
// TuskerComponents
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 11/9/22.
|
// Created by Shadowfacts on 11/9/22.
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
@ -39,7 +39,7 @@ struct AlertWithData<Data, A: View, M: View>: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
|
public func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
|
||||||
modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
|
modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -40,7 +40,6 @@
|
||||||
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; };
|
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; };
|
||||||
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
|
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
|
||||||
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.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 */; };
|
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
|
||||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
|
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
|
||||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
|
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
|
||||||
|
@ -184,7 +183,7 @@
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
||||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
||||||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; };
|
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; };
|
||||||
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; };
|
D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* OldDraft.swift */; };
|
||||||
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.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 */; };
|
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
|
||||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
|
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
|
||||||
|
@ -289,10 +288,11 @@
|
||||||
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
|
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
|
||||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
|
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
|
||||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
|
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
|
||||||
|
D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = D6BD395829B64426005FFD2B /* ComposeUI */; };
|
||||||
|
D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */; };
|
||||||
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
|
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
|
||||||
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
|
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
|
||||||
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.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 */; };
|
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
|
||||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
|
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
|
||||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
|
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
|
||||||
|
@ -456,7 +456,6 @@
|
||||||
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; };
|
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; };
|
||||||
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
|
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
|
||||||
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = "<group>"; };
|
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = "<group>"; };
|
|
||||||
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
|
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
|
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
|
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -600,7 +599,7 @@
|
||||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
||||||
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
|
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
|
||||||
D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = "<group>"; };
|
D677284D24ECC01D00C732D3 /* OldDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldDraft.swift; sourceTree = "<group>"; };
|
||||||
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
|
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
|
||||||
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
|
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
|
||||||
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
||||||
|
@ -706,11 +705,12 @@
|
||||||
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
|
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
|
||||||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
|
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
|
||||||
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
|
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6BD395729B6441F005FFD2B /* ComposeUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ComposeUI; path = Packages/ComposeUI; sourceTree = "<group>"; };
|
||||||
|
D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewComposeHostingController.swift; sourceTree = "<group>"; };
|
||||||
D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; };
|
D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; };
|
||||||
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
|
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
|
||||||
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
|
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
|
||||||
D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = "<group>"; };
|
D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = "<group>"; };
|
||||||
D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertWithData.swift; sourceTree = "<group>"; };
|
|
||||||
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
|
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
|
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
|
||||||
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
|
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -807,6 +807,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */,
|
||||||
D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */,
|
D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */,
|
||||||
D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */,
|
D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */,
|
||||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
||||||
|
@ -880,7 +881,7 @@
|
||||||
D6285B5221EA708700FE4B39 /* StatusFormat.swift */,
|
D6285B5221EA708700FE4B39 /* StatusFormat.swift */,
|
||||||
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */,
|
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */,
|
||||||
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
|
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
|
||||||
D677284D24ECC01D00C732D3 /* Draft.swift */,
|
D677284D24ECC01D00C732D3 /* OldDraft.swift */,
|
||||||
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
|
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
|
||||||
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
|
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
|
||||||
D65B4B532971F71D00DABDFB /* EditedReport.swift */,
|
D65B4B532971F71D00DABDFB /* EditedReport.swift */,
|
||||||
|
@ -1150,6 +1151,7 @@
|
||||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
||||||
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */,
|
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */,
|
||||||
D6BEA248291C6118002F4D01 /* DraftsView.swift */,
|
D6BEA248291C6118002F4D01 /* DraftsView.swift */,
|
||||||
|
D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */,
|
||||||
);
|
);
|
||||||
path = Compose;
|
path = Compose;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1446,7 +1448,6 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
||||||
D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */,
|
|
||||||
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
||||||
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
||||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||||
|
@ -1536,6 +1537,7 @@
|
||||||
D6B0026C29B5245400C70BE2 /* UserAccounts */,
|
D6B0026C29B5245400C70BE2 /* UserAccounts */,
|
||||||
D6FA94DF29B52891006AAC51 /* InstanceFeatures */,
|
D6FA94DF29B52891006AAC51 /* InstanceFeatures */,
|
||||||
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
|
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
|
||||||
|
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
||||||
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
||||||
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
||||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||||
|
@ -1607,7 +1609,6 @@
|
||||||
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
|
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
|
||||||
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
|
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
|
||||||
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
|
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
|
||||||
D6114E1627F8BB210080E273 /* VersionTests.swift */,
|
|
||||||
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
|
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
|
||||||
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
|
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
|
||||||
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */,
|
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */,
|
||||||
|
@ -1741,6 +1742,7 @@
|
||||||
D6B0026D29B5248800C70BE2 /* UserAccounts */,
|
D6B0026D29B5248800C70BE2 /* UserAccounts */,
|
||||||
D6FA94E029B52898006AAC51 /* InstanceFeatures */,
|
D6FA94E029B52898006AAC51 /* InstanceFeatures */,
|
||||||
D635237029B78A7D009ED5E7 /* TuskerComponents */,
|
D635237029B78A7D009ED5E7 /* TuskerComponents */,
|
||||||
|
D6BD395829B64426005FFD2B /* ComposeUI */,
|
||||||
);
|
);
|
||||||
productName = Tusker;
|
productName = Tusker;
|
||||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||||
|
@ -1985,6 +1987,7 @@
|
||||||
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
|
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
|
||||||
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
|
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
|
||||||
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
|
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
|
||||||
|
D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */,
|
||||||
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
|
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
|
||||||
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */,
|
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */,
|
||||||
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */,
|
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */,
|
||||||
|
@ -2155,7 +2158,6 @@
|
||||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
||||||
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
||||||
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */,
|
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */,
|
||||||
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
|
||||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||||
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
||||||
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
|
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
|
||||||
|
@ -2261,7 +2263,7 @@
|
||||||
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
|
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
|
||||||
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
|
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
|
||||||
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
|
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
|
||||||
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
|
D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */,
|
||||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
||||||
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
||||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
|
@ -2293,7 +2295,6 @@
|
||||||
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
||||||
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
||||||
D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */,
|
D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */,
|
||||||
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
|
|
||||||
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
|
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
|
||||||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
|
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
@ -2969,6 +2970,10 @@
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = UserAccounts;
|
productName = UserAccounts;
|
||||||
};
|
};
|
||||||
|
D6BD395829B64426005FFD2B /* ComposeUI */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ComposeUI;
|
||||||
|
};
|
||||||
D6BEA244291A0EDE002F4D01 /* Duckable */ = {
|
D6BEA244291A0EDE002F4D01 /* Duckable */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Duckable;
|
productName = Duckable;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import Combine
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
import InstanceFeatures
|
import InstanceFeatures
|
||||||
import Sentry
|
import Sentry
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
class MastodonController: ObservableObject {
|
class MastodonController: ObservableObject {
|
||||||
|
|
||||||
|
@ -456,6 +457,55 @@ class MastodonController: ObservableObject {
|
||||||
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
|
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?) {
|
private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
|
|
|
@ -12,12 +12,12 @@ import UniformTypeIdentifiers
|
||||||
|
|
||||||
class PostService: ObservableObject {
|
class PostService: ObservableObject {
|
||||||
private let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
private let draft: Draft
|
private let draft: OldDraft
|
||||||
let totalSteps: Int
|
let totalSteps: Int
|
||||||
|
|
||||||
@Published var currentStep = 1
|
@Published var currentStep = 1
|
||||||
|
|
||||||
init(mastodonController: MastodonController, draft: Draft) {
|
init(mastodonController: MastodonController, draft: OldDraft) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.draft = draft
|
self.draft = draft
|
||||||
// 2 steps (request data, then upload) for each attachment
|
// 2 steps (request data, then upload) for each attachment
|
||||||
|
@ -31,7 +31,7 @@ class PostService: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// save before posting, so if a crash occurs during network request, the status won't be lost
|
// save before posting, so if a crash occurs during network request, the status won't be lost
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
|
|
||||||
let uploadedAttachments = try await uploadAttachments()
|
let uploadedAttachments = try await uploadAttachments()
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ class PostService: ObservableObject {
|
||||||
let (_, _) = try await mastodonController.run(request)
|
let (_, _) = try await mastodonController.run(request)
|
||||||
currentStep += 1
|
currentStep += 1
|
||||||
|
|
||||||
DraftsManager.shared.remove(self.draft)
|
OldDraftsManager.shared.remove(self.draft)
|
||||||
} catch let error as Client.Error {
|
} catch let error as Client.Error {
|
||||||
throw Error.posting(error)
|
throw Error.posting(error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -321,7 +321,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO {
|
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)
|
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
|
||||||
return relationshipMO
|
return relationshipMO
|
||||||
} else {
|
} else {
|
||||||
|
@ -336,7 +336,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
let relationshipMO = self.upsert(relationship: relationship, in: context)
|
let relationshipMO = self.upsert(relationship: relationship, in: context)
|
||||||
self.save(context: context)
|
self.save(context: context)
|
||||||
completion?(relationshipMO)
|
completion?(relationshipMO)
|
||||||
self.relationshipSubject.send(relationship.id)
|
self.relationshipSubject.send(relationship.accountID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import CoreData
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
@objc(RelationshipMO)
|
@objc(RelationshipMO)
|
||||||
public final class RelationshipMO: NSManagedObject {
|
public final class RelationshipMO: NSManagedObject, RelationshipProtocol {
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<RelationshipMO> {
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<RelationshipMO> {
|
||||||
return NSFetchRequest<RelationshipMO>(entityName: "Relationship")
|
return NSFetchRequest<RelationshipMO>(entityName: "Relationship")
|
||||||
|
@ -29,6 +29,8 @@ public final class RelationshipMO: NSManagedObject {
|
||||||
@NSManaged public var showingReblogs: Bool
|
@NSManaged public var showingReblogs: Bool
|
||||||
@NSManaged public var account: AccountMO?
|
@NSManaged public var account: AccountMO?
|
||||||
|
|
||||||
|
public var followRequested: Bool { requested }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension RelationshipMO {
|
extension RelationshipMO {
|
||||||
|
@ -43,10 +45,10 @@ extension RelationshipMO {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.accountID = relationship.id
|
self.accountID = relationship.accountID
|
||||||
self.blocking = relationship.blocking
|
self.blocking = relationship.blocking
|
||||||
self.domainBlocking = relationship.domainBlocking
|
self.domainBlocking = relationship.domainBlocking
|
||||||
self.endorsed = relationship.endorsed ?? false
|
self.endorsed = relationship.endorsed
|
||||||
self.followedBy = relationship.followedBy
|
self.followedBy = relationship.followedBy
|
||||||
self.following = relationship.following
|
self.following = relationship.following
|
||||||
self.muting = relationship.muting
|
self.muting = relationship.muting
|
||||||
|
@ -54,6 +56,6 @@ extension RelationshipMO {
|
||||||
self.requested = relationship.followRequested
|
self.requested = relationship.followRequested
|
||||||
self.showingReblogs = relationship.showingReblogs
|
self.showingReblogs = relationship.showingReblogs
|
||||||
|
|
||||||
self.account = container.account(for: relationship.id, in: context)
|
self.account = container.account(for: relationship.accountID, in: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
extension Date {
|
extension Date {
|
||||||
|
|
||||||
|
@ -34,30 +35,7 @@ extension Date {
|
||||||
}
|
}
|
||||||
|
|
||||||
func timeAgoString() -> String {
|
func timeAgoString() -> String {
|
||||||
let (amount, component) = timeAgo()
|
self.formatted(.abbreviatedTimeAgo)
|
||||||
|
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,9 +70,9 @@ extension CompositionAttachment: NSItemProviderWriting {
|
||||||
} catch {
|
} catch {
|
||||||
completionHandler(nil, error)
|
completionHandler(nil, error)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// DraftsManager.swift
|
// OldDraftsManager.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 10/22/18.
|
// Created by Shadowfacts on 10/22/18.
|
||||||
|
@ -8,12 +8,12 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class DraftsManager: Codable, ObservableObject {
|
class OldDraftsManager: Codable, ObservableObject {
|
||||||
|
|
||||||
private(set) static var shared: DraftsManager = load()
|
private(set) static var shared: OldDraftsManager = load()
|
||||||
|
|
||||||
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||||
private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
private static var archiveURL = OldDraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||||
|
|
||||||
static func save() {
|
static func save() {
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
@ -23,13 +23,13 @@ class DraftsManager: Codable, ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func load() -> DraftsManager {
|
static func load() -> OldDraftsManager {
|
||||||
let decoder = PropertyListDecoder()
|
let decoder = PropertyListDecoder()
|
||||||
if let data = try? Data(contentsOf: archiveURL),
|
if let data = try? Data(contentsOf: archiveURL),
|
||||||
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
|
let OldDraftsManager = try? decoder.decode(OldDraftsManager.self, from: data) {
|
||||||
return draftsManager
|
return OldDraftsManager
|
||||||
}
|
}
|
||||||
return DraftsManager()
|
return OldDraftsManager()
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
@ -37,9 +37,9 @@ class DraftsManager: Codable, ObservableObject {
|
||||||
required init(from decoder: Decoder) throws {
|
required init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
if let dict = try? container.decode([UUID: Draft].self, forKey: .drafts) {
|
if let dict = try? container.decode([UUID: OldDraft].self, forKey: .drafts) {
|
||||||
self.drafts = dict
|
self.drafts = dict
|
||||||
} else if let array = try? container.decode([Draft].self, forKey: .drafts) {
|
} else if let array = try? container.decode([OldDraft].self, forKey: .drafts) {
|
||||||
self.drafts = array.reduce(into: [:], { partialResult, draft in
|
self.drafts = array.reduce(into: [:], { partialResult, draft in
|
||||||
partialResult[draft.id] = draft
|
partialResult[draft.id] = draft
|
||||||
})
|
})
|
||||||
|
@ -53,20 +53,20 @@ class DraftsManager: Codable, ObservableObject {
|
||||||
try container.encode(drafts, forKey: .drafts)
|
try container.encode(drafts, forKey: .drafts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published private var drafts: [UUID: Draft] = [:]
|
@Published private var drafts: [UUID: OldDraft] = [:]
|
||||||
var sorted: [Draft] {
|
var sorted: [OldDraft] {
|
||||||
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
|
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(_ draft: Draft) {
|
func add(_ draft: OldDraft) {
|
||||||
drafts[draft.id] = draft
|
drafts[draft.id] = draft
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(_ draft: Draft) {
|
func remove(_ draft: OldDraft) {
|
||||||
drafts.removeValue(forKey: draft.id)
|
drafts.removeValue(forKey: draft.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBy(id: UUID) -> Draft? {
|
func getBy(id: UUID) -> OldDraft? {
|
||||||
return drafts[id]
|
return drafts[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Draft.swift
|
// OldDraft.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 8/18/20.
|
// Created by Shadowfacts on 8/18/20.
|
||||||
|
@ -9,7 +9,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class Draft: Codable, ObservableObject {
|
class OldDraft: Codable, ObservableObject {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
var lastModified: Date
|
var lastModified: Date
|
||||||
|
|
||||||
|
@ -88,15 +88,15 @@ class Draft: Codable, ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Draft: Equatable {
|
extension OldDraft: Equatable {
|
||||||
static func ==(lhs: Draft, rhs: Draft) -> Bool {
|
static func ==(lhs: OldDraft, rhs: OldDraft) -> Bool {
|
||||||
return lhs.id == rhs.id
|
return lhs.id == rhs.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Draft: Identifiable {}
|
extension OldDraft: Identifiable {}
|
||||||
|
|
||||||
extension Draft {
|
extension OldDraft {
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case lastModified
|
case lastModified
|
||||||
|
@ -115,7 +115,7 @@ extension Draft {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Draft {
|
extension OldDraft {
|
||||||
class Poll: Codable, ObservableObject {
|
class Poll: Codable, ObservableObject {
|
||||||
@Published var options: [Option]
|
@Published var options: [Option]
|
||||||
@Published var multiple: Bool
|
@Published var multiple: Bool
|
||||||
|
@ -173,7 +173,7 @@ extension Draft {
|
||||||
|
|
||||||
extension MastodonController {
|
extension MastodonController {
|
||||||
|
|
||||||
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft {
|
func createOldDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> OldDraft {
|
||||||
var acctsToMention = [String]()
|
var acctsToMention = [String]()
|
||||||
|
|
||||||
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
|
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
|
||||||
|
@ -210,7 +210,7 @@ extension MastodonController {
|
||||||
}
|
}
|
||||||
acctsToMention = acctsToMention.uniques()
|
acctsToMention = acctsToMention.uniques()
|
||||||
|
|
||||||
let draft = Draft(accountID: accountInfo!.id)
|
let draft = OldDraft(accountID: accountInfo!.id)
|
||||||
draft.inReplyToID = inReplyToID
|
draft.inReplyToID = inReplyToID
|
||||||
draft.text = acctsToMention.map { "@\($0) " }.joined()
|
draft.text = acctsToMention.map { "@\($0) " }.joined()
|
||||||
draft.initialText = draft.text
|
draft.initialText = draft.text
|
||||||
|
@ -219,7 +219,7 @@ extension MastodonController {
|
||||||
draft.contentWarning = contentWarning
|
draft.contentWarning = contentWarning
|
||||||
draft.contentWarningEnabled = !contentWarning.isEmpty
|
draft.contentWarningEnabled = !contentWarning.isEmpty
|
||||||
|
|
||||||
DraftsManager.shared.add(draft)
|
OldDraftsManager.shared.add(draft)
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
||||||
|
|
||||||
|
@ -58,7 +59,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
controller.initialize()
|
controller.initialize()
|
||||||
|
|
||||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
let composeVC = NewComposeHostingController(draft: draft, mastodonController: controller)
|
||||||
composeVC.delegate = self
|
composeVC.delegate = self
|
||||||
let nav = EnhancedNavigationViewController(rootViewController: composeVC)
|
let nav = EnhancedNavigationViewController(rootViewController: composeVC)
|
||||||
|
|
||||||
|
@ -66,7 +67,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
||||||
window!.rootViewController = nav
|
window!.rootViewController = nav
|
||||||
window!.makeKeyAndVisible()
|
window!.makeKeyAndVisible()
|
||||||
|
|
||||||
updateTitle(draft: composeVC.draft)
|
updateTitle(draft: composeVC.controller.draft)
|
||||||
composeVC.controller.$draft
|
composeVC.controller.$draft
|
||||||
.sink { [unowned self] in self.updateTitle(draft: $0) }
|
.sink { [unowned self] in self.updateTitle(draft: $0) }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
@ -76,7 +77,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillResignActive(_ scene: UIScene) {
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
|
|
||||||
if let window = window,
|
if let window = window,
|
||||||
let nav = window.rootViewController as? UINavigationController,
|
let nav = window.rootViewController as? UINavigationController,
|
||||||
|
@ -108,8 +109,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeSceneDelegate: ComposeHostingControllerDelegate {
|
extension ComposeSceneDelegate: NewComposeHostingControllerDelegate {
|
||||||
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool {
|
func dismissCompose(mode: DismissMode) -> Bool {
|
||||||
let animation: UIWindowScene.DismissalAnimation
|
let animation: UIWindowScene.DismissalAnimation
|
||||||
switch mode {
|
switch mode {
|
||||||
case .cancel:
|
case .cancel:
|
||||||
|
|
|
@ -87,7 +87,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
|
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||||
|
|
||||||
Preferences.save()
|
Preferences.save()
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
@ -100,7 +100,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||||
|
|
||||||
Preferences.save()
|
Preferences.save()
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
||||||
|
|
|
@ -7,11 +7,12 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
struct ComposeAssetPicker: UIViewControllerRepresentable {
|
struct ComposeAssetPicker: UIViewControllerRepresentable {
|
||||||
typealias UIViewControllerType = AssetPickerViewController
|
typealias UIViewControllerType = AssetPickerViewController
|
||||||
|
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: OldDraft
|
||||||
let delegate: AssetPickerViewControllerDelegate?
|
let delegate: AssetPickerViewControllerDelegate?
|
||||||
|
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
|
|
|
@ -12,7 +12,7 @@ import AVFoundation
|
||||||
import Vision
|
import Vision
|
||||||
|
|
||||||
struct ComposeAttachmentRow: View {
|
struct ComposeAttachmentRow: View {
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: OldDraft
|
||||||
@ObservedObject var attachment: CompositionAttachment
|
@ObservedObject var attachment: CompositionAttachment
|
||||||
|
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
|
|
|
@ -12,7 +12,7 @@ struct ComposeAttachmentsList: View {
|
||||||
private let cellHeight: CGFloat = 80
|
private let cellHeight: CGFloat = 80
|
||||||
private let cellPadding: CGFloat = 12
|
private let cellPadding: CGFloat = 12
|
||||||
|
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: OldDraft
|
||||||
|
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
|
@ -119,13 +119,13 @@ struct ComposeAttachmentsList: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addAttachment() {
|
private func addAttachment() {
|
||||||
if #available(iOS 16.0, *) {
|
// if #available(iOS 16.0, *) {
|
||||||
isShowingAssetPickerPopover = true
|
// isShowingAssetPickerPopover = true
|
||||||
} else if horizontalSizeClass == .regular {
|
// } else if horizontalSizeClass == .regular {
|
||||||
isShowingAssetPickerPopover = true
|
// isShowingAssetPickerPopover = true
|
||||||
} else {
|
// } else {
|
||||||
uiState.delegate?.presentAssetPickerSheet()
|
uiState.delegate?.presentAssetPickerSheet()
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
||||||
|
@ -158,7 +158,7 @@ struct ComposeAttachmentsList: View {
|
||||||
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
|
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
draft.poll = draft.poll == nil ? Draft.Poll() : nil
|
draft.poll = draft.poll == nil ? OldDraft.Poll() : nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,14 +25,14 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
|
||||||
|
|
||||||
let uiState: ComposeUIState
|
let uiState: ComposeUIState
|
||||||
|
|
||||||
var draft: Draft { uiState.draft }
|
var draft: OldDraft { uiState.draft }
|
||||||
|
|
||||||
private var cancellables = [AnyCancellable]()
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
init(draft: Draft? = nil, mastodonController: MastodonController) {
|
init(draft: OldDraft? = nil, mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
|
let realDraft = draft ?? OldDraft(accountID: mastodonController.accountInfo!.id)
|
||||||
DraftsManager.shared.add(realDraft)
|
OldDraftsManager.shared.add(realDraft)
|
||||||
|
|
||||||
self.uiState = ComposeUIState(draft: realDraft)
|
self.uiState = ComposeUIState(draft: realDraft)
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
|
||||||
.flatMap(\.objectWillChange)
|
.flatMap(\.objectWillChange)
|
||||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
||||||
.sink {
|
.sink {
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNavigationTitle(draft: Draft) {
|
private func updateNavigationTitle(draft: OldDraft) {
|
||||||
if let id = draft.inReplyToID,
|
if let id = draft.inReplyToID,
|
||||||
let status = mastodonController.persistentContainer.status(for: id) {
|
let status = mastodonController.persistentContainer.status(for: id) {
|
||||||
navigationItem.title = "Reply to @\(status.account.acct)"
|
navigationItem.title = "Reply to @\(status.account.acct)"
|
||||||
|
@ -80,9 +80,9 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
if !draft.hasContent {
|
if !draft.hasContent {
|
||||||
DraftsManager.shared.remove(draft)
|
OldDraftsManager.shared.remove(draft)
|
||||||
}
|
}
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||||
|
@ -153,7 +153,7 @@ extension ComposeHostingController {
|
||||||
struct Wrapper: View {
|
struct Wrapper: View {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
@ObservedObject var uiState: ComposeUIState
|
@ObservedObject var uiState: ComposeUIState
|
||||||
var draft: Draft {
|
var draft: OldDraft {
|
||||||
uiState.draft
|
uiState.draft
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,11 +202,11 @@ extension ComposeHostingController: ComposeUIStateDelegate {
|
||||||
present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
|
present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectDraft(_ draft: Draft) {
|
func selectDraft(_ draft: OldDraft) {
|
||||||
if self.draft.hasContent {
|
if self.draft.hasContent {
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
} else {
|
} else {
|
||||||
DraftsManager.shared.remove(self.draft)
|
OldDraftsManager.shared.remove(self.draft)
|
||||||
}
|
}
|
||||||
uiState.draft = draft
|
uiState.draft = draft
|
||||||
uiState.isShowingDraftsList = false
|
uiState.isShowingDraftsList = false
|
||||||
|
@ -253,7 +253,7 @@ extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||||
DraftsManager.save()
|
OldDraftsManager.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,15 +18,15 @@ struct ComposePollView: View {
|
||||||
return f
|
return f
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: OldDraft
|
||||||
@ObservedObject var poll: Draft.Poll
|
@ObservedObject var poll: OldDraft.Poll
|
||||||
|
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||||
|
|
||||||
@State private var duration: Duration
|
@State private var duration: Duration
|
||||||
|
|
||||||
init(draft: Draft, poll: Draft.Poll) {
|
init(draft: OldDraft, poll: OldDraft.Poll) {
|
||||||
self.draft = draft
|
self.draft = draft
|
||||||
self.poll = poll
|
self.poll = poll
|
||||||
|
|
||||||
|
@ -130,7 +130,7 @@ struct ComposePollView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addOption() {
|
private func addOption() {
|
||||||
poll.options.append(Draft.Poll.Option(""))
|
poll.options.append(OldDraft.Poll.Option(""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,8 +167,8 @@ extension ComposePollView {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ComposePollOption: View {
|
struct ComposePollOption: View {
|
||||||
@ObservedObject var poll: Draft.Poll
|
@ObservedObject var poll: OldDraft.Poll
|
||||||
@ObservedObject var option: Draft.Poll.Option
|
@ObservedObject var option: OldDraft.Poll.Option
|
||||||
let optionIndex: Int
|
let optionIndex: Int
|
||||||
|
|
||||||
@EnvironmentObject private var mastodonController: MastodonController
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
|
|
|
@ -7,26 +7,31 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
struct ComposeReplyContentView: UIViewRepresentable {
|
struct ComposeReplyContentView: UIViewRepresentable {
|
||||||
typealias UIViewType = ComposeReplyContentTextView
|
typealias UIViewType = ComposeReplyContentTextView
|
||||||
|
|
||||||
let status: StatusMO
|
let status: any StatusProtocol
|
||||||
|
let mastodonController: MastodonController
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
|
||||||
|
|
||||||
let heightChanged: (CGFloat) -> Void
|
let heightChanged: (CGFloat) -> Void
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIViewType {
|
func makeUIView(context: Context) -> UIViewType {
|
||||||
let view = ComposeReplyContentTextView()
|
let view = ComposeReplyContentTextView()
|
||||||
view.overrideMastodonController = mastodonController
|
|
||||||
view.setTextFrom(status: status)
|
|
||||||
view.isUserInteractionEnabled = false
|
view.isUserInteractionEnabled = false
|
||||||
// scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line
|
// scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line
|
||||||
view.isScrollEnabled = true
|
view.isScrollEnabled = true
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
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
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ struct ComposeReplyView: View {
|
||||||
@State private var displayNameHeight: CGFloat?
|
@State private var displayNameHeight: CGFloat?
|
||||||
@State private var contentHeight: CGFloat?
|
@State private var contentHeight: CGFloat?
|
||||||
|
|
||||||
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
@ObservedObject private var preferences = Preferences.shared
|
@ObservedObject private var preferences = Preferences.shared
|
||||||
|
|
||||||
private let horizSpacing: CGFloat = 8
|
private let horizSpacing: CGFloat = 8
|
||||||
|
@ -46,7 +47,7 @@ struct ComposeReplyView: View {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ComposeReplyContentView(status: status) { newHeight in
|
ComposeReplyContentView(status: status, mastodonController: mastodonController) { newHeight in
|
||||||
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
|
// 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
|
// and it ends up partially behind the header
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
|
@ -16,7 +16,7 @@ struct ComposeToolbar: View {
|
||||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: OldDraft
|
||||||
|
|
||||||
@EnvironmentObject private var uiState: ComposeUIState
|
@EnvironmentObject private var uiState: ComposeUIState
|
||||||
@EnvironmentObject private var mastodonController: MastodonController
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
|
@ -144,6 +144,6 @@ private extension View {
|
||||||
|
|
||||||
struct ComposeToolbar_Previews: PreviewProvider {
|
struct ComposeToolbar_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ComposeToolbar(draft: Draft(accountID: ""))
|
ComposeToolbar(draft: OldDraft(accountID: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ protocol ComposeUIStateDelegate: AnyObject {
|
||||||
// @available(iOS, obsoleted: 16.0)
|
// @available(iOS, obsoleted: 16.0)
|
||||||
func presentAssetPickerSheet()
|
func presentAssetPickerSheet()
|
||||||
func presentComposeDrawing()
|
func presentComposeDrawing()
|
||||||
func selectDraft(_ draft: Draft)
|
func selectDraft(_ draft: OldDraft)
|
||||||
func paste(itemProviders: [NSItemProvider])
|
func paste(itemProviders: [NSItemProvider])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ class ComposeUIState: ObservableObject {
|
||||||
|
|
||||||
weak var delegate: ComposeUIStateDelegate?
|
weak var delegate: ComposeUIStateDelegate?
|
||||||
|
|
||||||
@Published var draft: Draft
|
@Published var draft: OldDraft
|
||||||
@Published var isShowingSaveDraftSheet = false
|
@Published var isShowingSaveDraftSheet = false
|
||||||
@Published var isShowingDraftsList = false
|
@Published var isShowingDraftsList = false
|
||||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||||
|
@ -35,7 +35,7 @@ class ComposeUIState: ObservableObject {
|
||||||
var shouldEmojiAutocompletionBeginExpanded = false
|
var shouldEmojiAutocompletionBeginExpanded = false
|
||||||
@Published var currentInput: ComposeInput?
|
@Published var currentInput: ComposeInput?
|
||||||
|
|
||||||
init(draft: Draft) {
|
init(draft: OldDraft) {
|
||||||
self.draft = draft
|
self.draft = draft
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
@propertyWrapper struct OptionalStateObject<T: ObservableObject>: DynamicProperty {
|
@propertyWrapper struct OptionalStateObject<T: ObservableObject>: DynamicProperty {
|
||||||
private class Republisher: ObservableObject {
|
private class Republisher: ObservableObject {
|
||||||
|
@ -44,7 +45,7 @@ import Combine
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
@EnvironmentObject var draft: Draft
|
@EnvironmentObject var draft: OldDraft
|
||||||
|
|
||||||
@State private var globalFrameOutsideList: CGRect = .zero
|
@State private var globalFrameOutsideList: CGRect = .zero
|
||||||
@State private var contentWarningBecomeFirstResponder = false
|
@State private var contentWarningBecomeFirstResponder = false
|
||||||
|
@ -63,7 +64,7 @@ struct ComposeView: View {
|
||||||
private var charactersRemaining: Int {
|
private var charactersRemaining: Int {
|
||||||
let limit = mastodonController.instanceFeatures.maxStatusChars
|
let limit = mastodonController.instanceFeatures.maxStatusChars
|
||||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance))
|
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instanceFeatures))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var requiresAttachmentDescriptions: Bool {
|
private var requiresAttachmentDescriptions: Bool {
|
||||||
|
@ -279,7 +280,7 @@ struct ComposeView: View {
|
||||||
if draft.hasContent {
|
if draft.hasContent {
|
||||||
uiState.isShowingSaveDraftSheet = true
|
uiState.isShowingSaveDraftSheet = true
|
||||||
} else {
|
} else {
|
||||||
DraftsManager.shared.remove(draft)
|
OldDraftsManager.shared.remove(draft)
|
||||||
uiState.delegate?.dismissCompose(mode: .cancel)
|
uiState.delegate?.dismissCompose(mode: .cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -293,7 +294,7 @@ struct ComposeView: View {
|
||||||
uiState.delegate?.dismissCompose(mode: .cancel)
|
uiState.delegate?.dismissCompose(mode: .cancel)
|
||||||
}),
|
}),
|
||||||
.destructive(Text("Delete Draft"), action: {
|
.destructive(Text("Delete Draft"), action: {
|
||||||
DraftsManager.shared.remove(draft)
|
OldDraftsManager.shared.remove(draft)
|
||||||
uiState.isShowingSaveDraftSheet = false
|
uiState.isShowingSaveDraftSheet = false
|
||||||
uiState.delegate?.dismissCompose(mode: .cancel)
|
uiState.delegate?.dismissCompose(mode: .cancel)
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -12,7 +12,7 @@ import SwiftUI
|
||||||
struct DraftsRepresentable: UIViewControllerRepresentable {
|
struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||||
typealias UIViewControllerType = UIHostingController<DraftsView>
|
typealias UIViewControllerType = UIHostingController<DraftsView>
|
||||||
|
|
||||||
let currentDraft: Draft
|
let currentDraft: OldDraft
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIHostingController<DraftsView> {
|
func makeUIViewController(context: Context) -> UIHostingController<DraftsView> {
|
||||||
|
@ -24,73 +24,74 @@ struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DraftsView: View {
|
struct DraftsView: View {
|
||||||
let currentDraft: Draft
|
let currentDraft: OldDraft
|
||||||
// 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
|
// 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
|
let mastodonController: MastodonController
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
@StateObject private var draftsManager = DraftsManager.shared
|
@StateObject private var draftsManager = OldDraftsManager.shared
|
||||||
@State private var draftForDifferentReply: Draft?
|
@State private var draftForDifferentReply: OldDraft?
|
||||||
|
|
||||||
private var visibleDrafts: [Draft] {
|
private var visibleDrafts: [OldDraft] {
|
||||||
draftsManager.sorted.filter {
|
draftsManager.sorted.filter {
|
||||||
$0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id
|
$0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
// NavigationView {
|
||||||
List {
|
// List {
|
||||||
ForEach(visibleDrafts) { draft in
|
// ForEach(visibleDrafts) { draft in
|
||||||
Button {
|
// Button {
|
||||||
maybeSelectDraft(draft)
|
// maybeSelectDraft(draft)
|
||||||
} label: {
|
// } label: {
|
||||||
DraftView(draft: draft)
|
// DraftView(draft: draft)
|
||||||
}
|
// }
|
||||||
.contextMenu {
|
// .contextMenu {
|
||||||
Button(role: .destructive) {
|
// Button(role: .destructive) {
|
||||||
draftsManager.remove(draft)
|
// OldDraftsManager.remove(draft)
|
||||||
} label: {
|
// } label: {
|
||||||
Label("Delete Draft", systemImage: "trash")
|
// Label("Delete Draft", systemImage: "trash")
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.onDrag {
|
// .onDrag {
|
||||||
let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id)
|
// let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id)
|
||||||
activity.displaysAuxiliaryScene = true
|
// activity.displaysAuxiliaryScene = true
|
||||||
return NSItemProvider(object: activity)
|
// return NSItemProvider(object: activity)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.onDelete { indices in
|
// .onDelete { indices in
|
||||||
indices
|
// indices
|
||||||
.map { visibleDrafts[$0] }
|
// .map { visibleDrafts[$0] }
|
||||||
.forEach { draftsManager.remove($0) }
|
// .forEach { OldDraftsManager.remove($0) }
|
||||||
}
|
// }
|
||||||
.appGroupedListRowBackground()
|
// .appGroupedListRowBackground()
|
||||||
}
|
// }
|
||||||
.listStyle(.plain)
|
// .listStyle(.plain)
|
||||||
.appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self)
|
// .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self)
|
||||||
.navigationTitle(Text("Drafts"))
|
// .navigationTitle(Text("Drafts"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
// .navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
// .toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
// ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
// Button("Cancel") {
|
||||||
uiState.isShowingDraftsList = false
|
// uiState.isShowingDraftsList = false
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
|
// .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
|
||||||
Button("Cancel", role: .cancel) {
|
// Button("Cancel", role: .cancel) {
|
||||||
draftForDifferentReply = nil
|
// draftForDifferentReply = nil
|
||||||
}
|
// }
|
||||||
Button("Restore Draft") {
|
// Button("Restore Draft") {
|
||||||
uiState.delegate?.selectDraft(draft)
|
// uiState.delegate?.selectDraft(draft)
|
||||||
}
|
// }
|
||||||
} message: { draft in
|
// } message: { draft in
|
||||||
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
// Text("The selected draft is a reply to a different post, do you wish to use it?")
|
||||||
}
|
// }
|
||||||
|
Text("drafts")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func maybeSelectDraft(_ draft: Draft) {
|
private func maybeSelectDraft(_ draft: OldDraft) {
|
||||||
if draft.inReplyToID != currentDraft.inReplyToID,
|
if draft.inReplyToID != currentDraft.inReplyToID,
|
||||||
currentDraft.hasContent {
|
currentDraft.hasContent {
|
||||||
draftForDifferentReply = draft
|
draftForDifferentReply = draft
|
||||||
|
@ -101,9 +102,9 @@ struct DraftsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DraftView: View {
|
struct DraftView: View {
|
||||||
@ObservedObject private var draft: Draft
|
@ObservedObject private var draft: OldDraft
|
||||||
|
|
||||||
init(draft: Draft) {
|
init(draft: OldDraft) {
|
||||||
self._draft = ObservedObject(wrappedValue: draft)
|
self._draft = ObservedObject(wrappedValue: draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
struct MainComposeTextView: View, PlaceholderViewProvider {
|
struct MainComposeTextView: View, PlaceholderViewProvider {
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: OldDraft
|
||||||
@State private var placeholder: PlaceholderView = Self.placeholderView()
|
@State private var placeholder: PlaceholderView = Self.placeholderView()
|
||||||
|
|
||||||
let minHeight: CGFloat = 150
|
let minHeight: CGFloat = 150
|
||||||
|
|
|
@ -0,0 +1,223 @@
|
||||||
|
//
|
||||||
|
// NewComposeHostingController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/6/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import ComposeUI
|
||||||
|
import Combine
|
||||||
|
import PhotosUI
|
||||||
|
import PencilKit
|
||||||
|
import Pachyderm
|
||||||
|
import CoreData
|
||||||
|
import Duckable
|
||||||
|
|
||||||
|
protocol NewComposeHostingControllerDelegate: AnyObject {
|
||||||
|
func dismissCompose(mode: DismissMode) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class NewComposeHostingController: UIHostingController<NewComposeHostingController.View>, DuckableViewController {
|
||||||
|
|
||||||
|
weak var delegate: NewComposeHostingControllerDelegate?
|
||||||
|
weak var duckableDelegate: DuckableViewControllerDelegate?
|
||||||
|
|
||||||
|
let controller: ComposeController
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
|
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
|
||||||
|
private var drawingCompletion: ((PKDrawing) -> Void)?
|
||||||
|
|
||||||
|
init(draft: Draft?, mastodonController: MastodonController) {
|
||||||
|
let draft = draft ?? mastodonController.createDraft()
|
||||||
|
DraftsManager.shared.add(draft)
|
||||||
|
|
||||||
|
self.controller = ComposeController(
|
||||||
|
draft: draft,
|
||||||
|
config: ComposeUIConfig(),
|
||||||
|
mastodonController: mastodonController,
|
||||||
|
fetchAvatar: { await ImageCache.avatars.get($0).1 },
|
||||||
|
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)) }
|
||||||
|
)
|
||||||
|
controller.currentAccount = mastodonController.account
|
||||||
|
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
|
super.init(rootView: View(mastodonController: mastodonController, controller: controller))
|
||||||
|
|
||||||
|
self.updateConfig()
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||||
|
return controller.canPaste(itemProviders: itemProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func paste(itemProviders: [NSItemProvider]) {
|
||||||
|
controller.paste(itemProviders: itemProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismiss(mode: DismissMode) {
|
||||||
|
if delegate?.dismissCompose(mode: mode) == true {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
dismiss(animated: true)
|
||||||
|
duckableDelegate?.duckableViewControllerWillDismiss(animated: 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)) {
|
||||||
|
controller.showToolbar = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func duckableViewControllerDidFinishAnimatingDuck() {
|
||||||
|
controller.showToolbar = true
|
||||||
|
}
|
||||||
|
|
||||||
|
struct View: SwiftUI.View {
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
let controller: ComposeController
|
||||||
|
|
||||||
|
var body: some SwiftUI.View {
|
||||||
|
ControllerView(controller: { controller })
|
||||||
|
.task {
|
||||||
|
if let account = try? await mastodonController.getOwnAccount() {
|
||||||
|
controller.currentAccount = account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = AccountMO.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "displayName LIKE[cd] %@ OR acct LIKE[cd] %@", wildcardedQuery, wildcardedQuery)
|
||||||
|
|
||||||
|
if let results = try? persistentContainer.viewContext.fetch(request) {
|
||||||
|
return results
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewComposeHostingController: PHPickerViewControllerDelegate {
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
dismiss(animated: true)
|
||||||
|
|
||||||
|
assetPickerCompletion?(results)
|
||||||
|
assetPickerCompletion = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewComposeHostingController: ComposeDrawingViewControllerDelegate {
|
||||||
|
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
|
||||||
|
dismiss(animated: true)
|
||||||
|
drawingCompletion = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
|
||||||
|
dismiss(animated: true)
|
||||||
|
drawingCompletion?(drawing)
|
||||||
|
drawingCompletion = nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import ScreenCorners
|
import ScreenCorners
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
class AccountSwitchingContainerViewController: UIViewController {
|
class AccountSwitchingContainerViewController: UIViewController {
|
||||||
|
|
||||||
|
|
|
@ -8,14 +8,15 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Duckable
|
import Duckable
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
extension DuckableContainerViewController: TuskerRootViewController {
|
extension DuckableContainerViewController: TuskerRootViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
|
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
|
||||||
if let compose = duckedViewController as? ComposeHostingController,
|
if let compose = duckedViewController as? NewComposeHostingController,
|
||||||
compose.draft.hasContent {
|
compose.controller.draft.hasContent {
|
||||||
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft)
|
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft)
|
||||||
}
|
}
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
||||||
|
|
|
@ -358,16 +358,16 @@ extension MenuActionProvider {
|
||||||
}),
|
}),
|
||||||
createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in
|
createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let draft = self.mastodonController!.createDraft()
|
|
||||||
|
var text = ""
|
||||||
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !title.isEmpty {
|
if !title.isEmpty {
|
||||||
draft.text += title
|
text += title
|
||||||
draft.text += ":\n"
|
text += ":\n"
|
||||||
}
|
}
|
||||||
draft.text += url.absoluteString
|
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
|
let draft = self.mastodonController!.createDraft(text: text)
|
||||||
draft.initialText = draft.text
|
|
||||||
self.navigationDelegate?.compose(editing: draft)
|
self.navigationDelegate?.compose(editing: draft)
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Duckable
|
import Duckable
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol UserActivityHandlingContext {
|
protocol UserActivityHandlingContext {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import Intents
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import OSLog
|
import OSLog
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import SafariServices
|
import SafariServices
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import ComposeUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
||||||
|
@ -97,16 +98,26 @@ extension TuskerNavigationDelegate {
|
||||||
options.preferredPresentationStyle = .prominent
|
options.preferredPresentationStyle = .prominent
|
||||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
|
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
|
||||||
} else {
|
} else {
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
let compose = NewComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
if #available(iOS 16.0, *),
|
if #available(iOS 16.0, *),
|
||||||
presentDuckable(compose, animated: animated, isDucked: isDucked) {
|
presentDuckable(compose, animated: animated, isDucked: isDucked) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
|
||||||
let nav = UINavigationController(rootViewController: compose)
|
let nav = UINavigationController(rootViewController: compose)
|
||||||
nav.presentationController?.delegate = compose
|
// TODO: is this still necessary?
|
||||||
|
// nav.presentationController?.delegate = compose
|
||||||
present(nav, animated: animated)
|
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)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ class StatusContentTextView: ContentTextView {
|
||||||
|
|
||||||
private var statusID: String?
|
private var statusID: String?
|
||||||
|
|
||||||
func setTextFrom(status: StatusMO, precomputed attributedText: NSAttributedString? = nil) {
|
func setTextFrom(status: some StatusProtocol, precomputed attributedText: NSAttributedString? = nil) {
|
||||||
statusID = status.id
|
statusID = status.id
|
||||||
if let attributedText {
|
if let attributedText {
|
||||||
self.attributedText = attributedText
|
self.attributedText = attributedText
|
||||||
|
|
|
@ -34,28 +34,28 @@ class ComposeTests: TuskerUITests {
|
||||||
XCTAssertFalse(app.staticTexts["What's on your mind?"].exists, "placeholder does not exist")
|
XCTAssertFalse(app.staticTexts["What's on your mind?"].exists, "placeholder does not exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCharacterCounter() {
|
// func testCharacterCounter() {
|
||||||
XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500")
|
// XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500")
|
||||||
let textView = app.textViews.firstMatch
|
// let textView = app.textViews.firstMatch
|
||||||
|
//
|
||||||
let fragments = [
|
// let fragments = [
|
||||||
"Hello",
|
// "Hello",
|
||||||
"World",
|
// "World",
|
||||||
"@admin",
|
// "@admin",
|
||||||
"@admin@example.com",
|
// "@admin@example.com",
|
||||||
"https://foo.example.com/?bar=baz#qux",
|
// "https://foo.example.com/?bar=baz#qux",
|
||||||
]
|
// ]
|
||||||
|
//
|
||||||
var remaining = 500
|
// var remaining = 500
|
||||||
for s in fragments {
|
// for s in fragments {
|
||||||
let length = CharacterCounter.count(text: s)
|
// let length = CharacterCounter.count(text: s)
|
||||||
// add 1 for newline
|
// // add 1 for newline
|
||||||
remaining -= length + 1
|
// remaining -= length + 1
|
||||||
|
//
|
||||||
textView.typeText(s + "\n")
|
// textView.typeText(s + "\n")
|
||||||
XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)")
|
// XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)")
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// func testToolbarSwitching() {
|
// func testToolbarSwitching() {
|
||||||
// // text view is automatically focused, so unfocus
|
// // text view is automatically focused, so unfocus
|
||||||
|
|
Loading…
Reference in New Issue