Compare commits

..

1 Commits

Author SHA1 Message Date
Shadowfacts 01cf597b5d Account for bidi text in combined display/username label 2024-10-22 17:51:58 -04:00
31 changed files with 68 additions and 1758 deletions

View File

@ -20,14 +20,13 @@ let package = Package(
.package(path: "../InstanceFeatures"), .package(path: "../InstanceFeatures"),
.package(path: "../TuskerComponents"), .package(path: "../TuskerComponents"),
.package(path: "../MatchedGeometryPresentation"), .package(path: "../MatchedGeometryPresentation"),
.package(path: "../TuskerPreferences"),
], ],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // 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. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "ComposeUI", name: "ComposeUI",
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences"], dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
swiftSettings: [ swiftSettings: [
.swiftLanguageMode(.v5) .swiftLanguageMode(.v5)
]), ]),

View File

@ -11,7 +11,7 @@ import Pachyderm
import UniformTypeIdentifiers import UniformTypeIdentifiers
@MainActor @MainActor
final class PostService: ObservableObject { class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext private let mastodonController: ComposeMastodonContext
private let config: ComposeUIConfig private let config: ComposeUIConfig
private let draft: Draft private let draft: Draft

View File

@ -12,7 +12,7 @@ import InstanceFeatures
public struct CharacterCounter { public struct CharacterCounter {
private 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 instanceFeatures: InstanceFeatures) -> Int { public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
let mentionsRemoved = removeMentions(in: text) let mentionsRemoved = removeMentions(in: text)

View File

@ -8,7 +8,6 @@
import Foundation import Foundation
import Combine import Combine
import UIKit import UIKit
import SwiftUI
protocol ComposeInput: AnyObject, ObservableObject { protocol ComposeInput: AnyObject, ObservableObject {
var toolbarElements: [ToolbarElement] { get } var toolbarElements: [ToolbarElement] { get }
@ -28,44 +27,3 @@ enum ToolbarElement {
case emojiPicker case emojiPicker
case formattingButtons case formattingButtons
} }
private struct FocusedComposeInput: FocusedValueKey {
typealias Value = any ComposeInput
}
extension FocusedValues {
var composeInput: (any ComposeInput)? {
get { self[FocusedComposeInput.self] }
set { self[FocusedComposeInput.self] = newValue }
}
}
@propertyWrapper
final class MutableObservableBox<Value>: ObservableObject {
@Published var wrappedValue: Value
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}
private struct FocusedComposeInputBox: EnvironmentKey {
static let defaultValue: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
}
extension EnvironmentValues {
var composeInputBox: MutableObservableBox<(any ComposeInput)?> {
get { self[FocusedComposeInputBox.self] }
set { self[FocusedComposeInputBox.self] = newValue }
}
}
struct FocusedInputModifier: ViewModifier {
@StateObject var box: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
func body(content: Content) -> some View {
content
.environment(\.composeInputBox, box)
.focusedValue(\.composeInput, box.wrappedValue)
}
}

View File

@ -9,7 +9,6 @@ import Foundation
import Pachyderm import Pachyderm
import InstanceFeatures import InstanceFeatures
import UserAccounts import UserAccounts
import SwiftUI
public protocol ComposeMastodonContext { public protocol ComposeMastodonContext {
var accountInfo: UserAccountInfo? { get } var accountInfo: UserAccountInfo? { get }
@ -27,6 +26,4 @@ public protocol ComposeMastodonContext {
func searchCachedHashtags(query: String) -> [Hashtag] func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status) func storeCreatedStatus(_ status: Status)
func fetchStatus(id: String) -> (any StatusProtocol)?
} }

View File

@ -181,7 +181,7 @@ extension EnvironmentValues {
} }
} }
struct GIFViewWrapper: UIViewRepresentable { private struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView typealias UIViewType = GIFImageView
@State var controller: GIFController @State var controller: GIFController

View File

@ -130,17 +130,11 @@ public final class ComposeController: ViewController {
} }
public var view: some View { public var view: some View {
if Preferences.shared.hasFeatureFlag(.composeRewrite) { ComposeView(poster: poster)
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController) .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environment(\.currentAccount, currentAccount) .environmentObject(draft)
.environment(\.composeUIConfig, config) .environmentObject(mastodonController.instanceFeatures)
} else { .environment(\.composeUIConfig, config)
ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures)
.environment(\.composeUIConfig, config)
}
} }
@MainActor @MainActor

View File

@ -17,7 +17,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
Text("Happy π day!") Text("Happy π day!")
} else if components.month == 4 && components.day == 1 { } else if components.month == 4 && components.day == 1 {
Text("April Fools!").rotationEffect(.radians(.pi), anchor: .center) Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
} else if components.month == 9 && components.day == 5 { } else if components.month == 9 && components.day == 5 {
// https://weirder.earth/@noracodes/109276419847254552 // https://weirder.earth/@noracodes/109276419847254552
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990 // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
@ -31,7 +31,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
Text("Any questions?") Text("Any questions?")
} }
} else { } else {
Text("Whats on your mind?") Text("What's on your mind?")
} }
} }
@ -41,7 +41,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
} }
// exists to provide access to the type alias since the @State property needs it to be explicit // exists to provide access to the type alias since the @State property needs it to be explicit
protocol PlaceholderViewProvider { private protocol PlaceholderViewProvider {
associatedtype PlaceholderView: View associatedtype PlaceholderView: View
@ViewBuilder @ViewBuilder
static func makePlaceholderView() -> PlaceholderView static func makePlaceholderView() -> PlaceholderView

View File

@ -1,49 +0,0 @@
//
// Environment.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import Pachyderm
//@propertyWrapper
//struct RequiredEnvironment<Value>: DynamicProperty {
// private let keyPath: KeyPath<EnvironmentValues, Value?>
// @Environment private var value: Value?
//
// init(_ keyPath: KeyPath<EnvironmentValues, Value?>) {
// self.keyPath = keyPath
// self._value = Environment(keyPath)
// }
//
// var wrappedValue: Value {
// guard let value else {
// preconditionFailure("Missing required environment value for \(keyPath)")
// }
// return value
// }
//}
private struct ComposeMastodonContextKey: EnvironmentKey {
static let defaultValue: (any ComposeMastodonContext)? = nil
}
extension EnvironmentValues {
var mastodonController: (any ComposeMastodonContext)? {
get { self[ComposeMastodonContextKey.self] }
set { self[ComposeMastodonContextKey.self] = newValue }
}
}
private struct CurrentAccountKey: EnvironmentKey {
static let defaultValue: (any AccountProtocol)? = nil
}
extension EnvironmentValues {
var currentAccount: (any AccountProtocol)? {
get { self[CurrentAccountKey.self] }
set { self[CurrentAccountKey.self] = newValue }
}
}

View File

@ -11,6 +11,7 @@ import UIKit
import Combine import Combine
class KeyboardReader: ObservableObject { class KeyboardReader: ObservableObject {
// @Published var isVisible = false
@Published var keyboardHeight: CGFloat = 0 @Published var keyboardHeight: CGFloat = 0
var isVisible: Bool { var isVisible: Bool {
@ -25,12 +26,14 @@ class KeyboardReader: ObservableObject {
@objc func willShow(_ notification: Foundation.Notification) { @objc func willShow(_ notification: Foundation.Notification) {
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// isVisible = endFrame.height > 72
keyboardHeight = endFrame.height keyboardHeight = endFrame.height
} }
@objc func willHide() { @objc func willHide() {
// sometimes willHide is called during a SwiftUI view update // sometimes willHide is called during a SwiftUI view update
DispatchQueue.main.async { DispatchQueue.main.async {
// self.isVisible = false
self.keyboardHeight = 0 self.keyboardHeight = 0
} }
} }

View File

@ -9,13 +9,9 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
enum StatusFormat: Int, CaseIterable, Identifiable { enum StatusFormat: Int, CaseIterable {
case bold, italics, strikethrough, code case bold, italics, strikethrough, code
var id: some Hashable {
rawValue
}
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? { func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
switch contentType { switch contentType {
case .plain: case .plain:

View File

@ -1,11 +0,0 @@
//
// Preferences.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import Foundation
import TuskerPreferences
typealias Preferences = TuskerPreferences.Preferences

View File

@ -11,7 +11,6 @@ import Combine
public protocol ViewController: ObservableObject { public protocol ViewController: ObservableObject {
associatedtype ContentView: View associatedtype ContentView: View
@MainActor
@ViewBuilder @ViewBuilder
var view: ContentView { get } var view: ContentView { get }
} }

View File

@ -1,182 +0,0 @@
//
// AttachmentRowView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/18/24.
//
import SwiftUI
import InstanceFeatures
import Vision
struct AttachmentRowView: View {
@ObservedObject var attachment: DraftAttachment
@State private var isRecognizingText = false
@State private var textRecognitionError: (any Error)?
private var thumbnailSize: CGFloat {
#if os(visionOS)
120
#else
80
#endif
}
var body: some View {
HStack(alignment: .center, spacing: 4) {
thumbnailView
descriptionView
}
.alertWithData("Text Recognition Failed", data: $textRecognitionError) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
// TODO: attachments missing descriptions feature
private var thumbnailView: some View {
AttachmentThumbnailView(attachment: attachment)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: thumbnailSize, height: thumbnailSize)
.contextMenu {
EditDrawingButton(attachment: attachment)
RecognizeTextButton(attachment: attachment, isRecognizingText: $isRecognizingText, error: $textRecognitionError)
DeleteButton(attachment: attachment)
} preview: {
// TODO: need to fix flash of preview changing size
AttachmentThumbnailView(attachment: attachment)
}
}
@ViewBuilder
private var descriptionView: some View {
if isRecognizingText {
ProgressView()
.progressViewStyle(.circular)
} else {
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
}
}
}
private struct EditDrawingButton: View {
@ObservedObject var attachment: DraftAttachment
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
if attachment.drawingData != nil {
Button(action: editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
}
}
private func editDrawing() {
if case .drawing(let drawing) = attachment.data {
presentDrawing?(drawing) {
attachment.drawing = $0
}
}
}
}
private struct RecognizeTextButton: View {
@ObservedObject var attachment: DraftAttachment
@Binding var isRecognizingText: Bool
@Binding var error: (any Error)?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
if attachment.type == .image {
Button {
Task {
await recognizeText()
}
} label: {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
}
private func recognizeText() async {
isRecognizingText = true
defer { isRecognizingText = false }
do {
let data = try await getAttachmentData()
let observations = try await runRecognizeTextRequest(data: data)
if let observations {
var text = ""
for observation in observations {
let result = observation.topCandidates(1).first!
text.append(result.string)
text.append("\n")
}
self.attachment.attachmentDescription = text
}
} catch let error as NSError where error.domain == VNErrorDomain && 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 {
self.error = error
}
}
private func getAttachmentData() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
attachment.getData(features: instanceFeatures) { result in
switch result {
case .success(let (data, _)):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func runRecognizeTextRequest(data: Data) async throws -> [VNRecognizedTextObservation]? {
return try await withCheckedThrowingContinuation { continuation in
let handler = VNImageRequestHandler(data: data)
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: request.results as? [VNRecognizedTextObservation])
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
try? handler.perform([request])
}
}
}
}
private struct DeleteButton: View {
let attachment: DraftAttachment
var body: some View {
Button(role: .destructive, action: removeAttachment) {
Label("Delete", systemImage: "trash")
}
}
private func removeAttachment() {
let draft = attachment.draft
var array = draft.draftAttachments
guard let index = array.firstIndex(of: attachment) else {
return
}
array.remove(at: index)
draft.attachments = NSMutableOrderedSet(array: array)
}
}
//#Preview {
// AttachmentRowView()
//}

View File

@ -1,114 +0,0 @@
//
// AttachmentThumbnailView.swift
// ComposeUI
//
// Created by Shadowfacts on 10/14/24.
//
import SwiftUI
import TuskerComponents
import AVFoundation
import Photos
struct AttachmentThumbnailView: View {
let attachment: DraftAttachment
let contentMode: ContentMode = .fit
@State private var mode: Mode = .empty
@EnvironmentObject private var composeController: ComposeController
var body: some View {
switch mode {
case .empty:
Image(systemName: "photo")
.task {
await loadThumbnail()
}
case .image(let image):
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
case .gifController(let controller):
GIFViewWrapper(controller: controller)
}
}
private func loadThumbnail() async {
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
if let image = await composeController.fetchAttachment(url) {
self.mode = .image(image)
}
case .video, .gifv:
await loadVideoThumbnail(url: url)
case .audio, .unknown:
break
}
case .asset(let id):
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return
}
let isGIF = PHAssetResource.assetResources(for: asset).contains {
$0.uniformTypeIdentifier == UTType.gif.identifier
}
if isGIF {
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
guard let data else { return }
if typeIdentifier == UTType.gif.identifier {
self.mode = .gifController(GIFController(gifData: data))
} else if let image = UIImage(data: data) {
self.mode = .image(image)
}
}
} else {
let size = CGSize(width: 80, height: 80)
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
if let image {
self.mode = .image(image)
}
}
}
case .drawing(let drawing):
self.mode = .image(drawing.imageInLightMode(from: drawing.bounds))
case .file(let url, let type):
if type.conforms(to: .movie) {
await loadVideoThumbnail(url: url)
} else if let data = try? Data(contentsOf: url) {
if type == .gif {
self.mode = .gifController(GIFController(gifData: data))
} else if type.conforms(to: .image) {
if let image = UIImage(data: data),
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
let prepared = await image.byPreparingForDisplay() {
self.mode = .image(prepared)
}
}
}
case .none:
break
}
}
private func loadVideoThumbnail(url: URL) async {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
self.mode = .image(UIImage(cgImage: cgImage))
}
}
enum Mode {
case empty
case image(UIImage)
case gifController(GIFController)
}
}

View File

@ -1,351 +0,0 @@
//
// AttachmentsListView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/16/24.
//
import SwiftUI
import InstanceFeatures
import CoreData
import PhotosUI
struct AttachmentsListView: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
} else {
return true
}
}
private var callbacks: Callbacks {
Callbacks(draft: draft, presentAssetPicker: presentAssetPicker)
}
var body: some View {
WrappedCollectionView(attachments: draft.draftAttachments, hasPoll: draft.poll != nil, callbacks: callbacks, canAddAttachment: canAddAttachment)
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
// view from laying out, and leaving the intrinsic content size at zero too.
.frame(minHeight: 50)
.padding(.horizontal, -8)
.environmentObject(instanceFeatures)
}
}
private struct Callbacks {
let draft: Draft
let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
// guard self.canAddAttachment else {
// return
// }
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
func removeAttachment(at index: Int) {
var array = draft.draftAttachments
array.remove(at: index)
draft.attachments = NSMutableOrderedSet(array: array)
}
func reorderAttachments(with difference: CollectionDifference<DraftAttachment>) {
let array = draft.draftAttachments.applying(difference)!
draft.attachments = NSMutableOrderedSet(array: array)
}
func addPhoto() {
presentAssetPicker?() {
insertAttachments(at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
}
}
func addDrawing() {
}
func togglePoll() {
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
}
}
@available(iOS 16.0, *)
private struct WrappedCollectionView: UIViewRepresentable {
let attachments: [DraftAttachment]
let hasPoll: Bool
let callbacks: Callbacks
let canAddAttachment: Bool
func makeUIView(context: Context) -> UICollectionView {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.trailingSwipeActionsConfigurationProvider = { indexPath in
context.coordinator.trailingSwipeActions(for: indexPath)
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
context.coordinator.setHeightOfCellBeingDeleted = {
view.heightOfCellBeingDeleted = $0
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: view) { collectionView, indexPath, itemIdentifier in
context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier)
}
view.dataSource = dataSource
context.coordinator.dataSource = dataSource
view.delegate = context.coordinator
view.isScrollEnabled = false
dataSource.reorderingHandlers.canReorderItem = {
if case .attachment(_) = $0 {
true
} else {
false
}
}
dataSource.reorderingHandlers.didReorder = { transaction in
let attachmentsChanges = transaction.difference.map {
switch $0 {
case .insert(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
case .remove(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
}
}
let attachmentsDiff = CollectionDifference(attachmentsChanges)!
callbacks.reorderAttachments(with: attachmentsDiff)
}
let longPressRecognizer = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(WrappedCollectionViewCoordinator.reorderingLongPressRecognized))
longPressRecognizer.delegate = context.coordinator
view.addGestureRecognizer(longPressRecognizer)
return view
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.attachments, .buttons])
snapshot.appendItems(attachments.map {
.attachment($0)
}, toSection: .attachments)
snapshot.appendItems([
.button(.addPhoto, enabled: canAddAttachment),
.button(.addDrawing, enabled: canAddAttachment),
.button(.togglePoll(adding: !hasPoll), enabled: true)
], toSection: .buttons)
context.coordinator.dataSource.apply(snapshot)
context.coordinator.callbacks = callbacks
}
func makeCoordinator() -> WrappedCollectionViewCoordinator {
WrappedCollectionViewCoordinator(callbacks: callbacks)
}
enum Section: Hashable {
case attachments, buttons
}
enum Item: Hashable {
case attachment(DraftAttachment)
case button(Button, enabled: Bool)
static func ==(lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case let (.attachment(a), .attachment(b)):
return a.objectID == b.objectID
case let (.button(a, aEnabled), .button(b, bEnabled)):
return a == b && aEnabled == bEnabled
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .attachment(let draftAttachment):
hasher.combine(0)
hasher.combine(draftAttachment.objectID)
case .button(let button, let enabled):
hasher.combine(1)
hasher.combine(button)
hasher.combine(enabled)
}
}
}
enum Button: Hashable {
case addPhoto
case addDrawing
case togglePoll(adding: Bool)
}
}
private final class IntrinsicContentSizeCollectionView: UICollectionView {
private var _intrinsicContentSize = CGSize.zero
// This hack is necessary because the content size changes at the beginning of the cell delete animation,
// resulting in the bottommost cell being clipped.
var heightOfCellBeingDeleted: CGFloat = 0 {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
var size = _intrinsicContentSize
size.height += heightOfCellBeingDeleted
return size
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize != _intrinsicContentSize {
_intrinsicContentSize = contentSize
invalidateIntrinsicContentSize()
}
}
}
@available(iOS 16.0, *)
private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate, UIGestureRecognizerDelegate {
var callbacks: Callbacks
var setHeightOfCellBeingDeleted: ((CGFloat) -> Void)?
var dataSource: UICollectionViewDiffableDataSource<WrappedCollectionView.Section, WrappedCollectionView.Item>!
init(callbacks: Callbacks) {
self.callbacks = callbacks
}
private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in
cell.contentConfiguration = UIHostingConfiguration {
AttachmentRowView(attachment: item)
}
}
private let buttonCell = UICollectionView.CellRegistration<UICollectionViewListCell, (WrappedCollectionView.Button, Bool)> { cell, indexPath, item in
var config = cell.defaultContentConfiguration()
switch item.0 {
case .addPhoto:
config.image = UIImage(systemName: "photo")
config.text = "Add photo or video"
case .addDrawing:
config.image = UIImage(systemName: "hand.draw")
config.text = "Add drawing"
case .togglePoll(let adding):
config.image = UIImage(systemName: "chart.bar.doc.horizontal")
config.text = adding ? "Add a poll" : "Remove poll"
}
config.textProperties.color = .tintColor
if !item.1 {
config.textProperties.colorTransformer = .monochromeTint
config.imageProperties.tintColorTransformer = .monochromeTint
}
cell.contentConfiguration = config
}
func makeCell(collectionView: UICollectionView, indexPath: IndexPath, item: WrappedCollectionView.Item) -> UICollectionViewCell {
switch item {
case .attachment(let attachment):
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
case .button(let button, let enabled):
return collectionView.dequeueConfiguredReusableCell(using: buttonCell, for: indexPath, item: (button, enabled))
}
}
func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard case .attachment(let attachment) = dataSource.itemIdentifier(for: indexPath) else {
return nil
}
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: "Delete", handler: { _, view, completion in
self.setHeightOfCellBeingDeleted?(view.bounds.height)
// Actually remove the attachment immediately, so that (potentially) the buttons enabling animates.
self.callbacks.removeAttachment(at: indexPath.row)
// Also manually apply a snapshot removing the attachment item, otherwise the delete swipe action animation is messed up.
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems([.attachment(attachment)])
self.dataSource.apply(snapshot) {
self.setHeightOfCellBeingDeleted?(0)
completion(true)
}
})
])
}
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath)! {
case .attachment:
return false
case .button(_, let enabled):
return enabled
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: false)
guard case .button(let button, _) = dataSource.itemIdentifier(for: indexPath) else {
return
}
switch button {
case .addPhoto:
callbacks.addPhoto()
case .addDrawing:
callbacks.addDrawing()
case .togglePoll:
callbacks.togglePoll()
}
}
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let snapshot = dataSource.snapshot()
let attachmentsSection = snapshot.indexOfSection(.attachments)!
if proposedIndexPath.section != attachmentsSection {
return IndexPath(item: snapshot.itemIdentifiers(inSection: .attachments).count - 1, section: attachmentsSection)
} else {
return proposedIndexPath
}
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let collectionView = gestureRecognizer.view as! UICollectionView
let location = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location) else {
return false
}
return collectionView.beginInteractiveMovementForItem(at: indexPath)
}
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
let collectionView = recognizer.view as! UICollectionView
switch recognizer.state {
case .began:
break
case .changed:
collectionView.updateInteractiveMovementTargetPosition(recognizer.location(in: collectionView))
case .ended:
collectionView.endInteractiveMovement()
case .cancelled:
collectionView.cancelInteractiveMovement()
default:
break
}
}
}
//#Preview {
// AttachmentsListView()
//}

View File

@ -1,272 +0,0 @@
//
// ComposeToolbarView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import TuskerComponents
import InstanceFeatures
import Pachyderm
import TuskerPreferences
struct ComposeToolbarView: View {
static let height: CGFloat = 44
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
#if os(visionOS)
buttons
#else
ToolbarScrollView {
buttons
.padding(.horizontal, 16)
}
.frame(height: Self.height)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
.overlay(alignment: .top) {
Divider()
.ignoresSafeArea(edges: [.leading, .trailing])
}
#endif
}
private var buttons: some View {
HStack(spacing: 0) {
ContentWarningButton(enabled: $draft.contentWarningEnabled, focusedField: $focusedField)
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
InsertEmojiButton()
FormatButtons()
Spacer()
LangaugeButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
}
}
}
#if !os(visionOS)
private struct ToolbarScrollView<Content: View>: View {
@ViewBuilder let content: Content
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
content
.frame(minWidth: minWidth)
.background {
GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) {
realWidth = $0
}
}
}
}
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
.frame(maxWidth: .infinity)
.background {
GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) {
minWidth = $0
}
}
}
}
}
#endif
private struct ToolbarWidthPrefKey: SwiftUI.PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
private struct ContentWarningButton: View {
@Binding var enabled: Bool
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
Button("CW", action: toggleContentWarning)
.accessibilityLabel(enabled ? "Remove content warning" : "Add content warning")
.padding(5)
.hoverEffect()
}
private func toggleContentWarning() {
enabled.toggle()
if enabled {
focusedField = .contentWarning
}
}
}
private struct VisibilityButton: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
private var visibilityBinding: Binding<Pachyderm.Visibility> {
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
// changing the visibility when local-only.
if draft.localOnly,
instanceFeatures.localOnlyPostsVisibility {
return .constant(.public)
} else {
return $draft.visibility
}
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility]
if !instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private]
} else {
visibilities = Pachyderm.Visibility.allCases
}
return visibilities.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
}
var body: some View {
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
}
}
private struct LocalOnlyButton: View {
@Binding var enabled: Bool
var mastodonController: any ComposeMastodonContext
@ObservedObject private var instanceFeatures: InstanceFeatures
init(enabled: Binding<Bool>, mastodonController: any ComposeMastodonContext) {
self._enabled = enabled
self.mastodonController = mastodonController
self.instanceFeatures = mastodonController.instanceFeatures
}
private var options: [MenuPicker<Bool>.Option] {
let domain = mastodonController.accountInfo!.instanceURL.host!
return [
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
]
}
var body: some View {
if mastodonController.instanceFeatures.localOnlyPosts {
MenuPicker(selection: $enabled, options: options, buttonStyle: .iconOnly)
}
}
}
private struct InsertEmojiButton: View {
@FocusedValue(\.composeInput) private var input
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View {
if input?.toolbarElements.contains(.emojiPicker) == true {
Button(action: beginAutocompletingEmoji) {
Label("Insert custom emoji", systemImage: "face.smiling")
}
.labelStyle(.iconOnly)
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
}
private func beginAutocompletingEmoji() {
input?.beginAutocompletingEmoji()
}
}
private struct FormatButtons: View {
@FocusedValue(\.composeInput) private var input
@PreferenceObserving(\.$statusContentType) private var contentType
var body: some View {
if let input,
input.toolbarElements.contains(.formattingButtons),
contentType != .plain {
Spacer()
ForEach(StatusFormat.allCases) { format in
FormatButton(format: format, input: input)
}
}
}
}
private struct FormatButton: View {
let format: StatusFormat
let input: any ComposeInput
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View {
Button(action: applyFormat) {
Image(systemName: format.imageName)
.font(.system(size: imageSize))
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
private func applyFormat() {
input.applyFormat(format)
}
}
private struct LangaugeButton: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@FocusedValue(\.composeInput) private var input
@State private var hasChanged = false
var body: some View {
if instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
.onChange(of: draft.id) { _ in
hasChanged = false
}
}
}
@available(iOS 16.0, *)
private func currentInputModeChanged(_ notification: Foundation.Notification) {
guard !hasChanged,
!draft.hasContent,
let mode = input?.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode) else {
return
}
draft.language = code.identifier
}
}
//#Preview {
// ComposeToolbarView()
//}

View File

@ -1,221 +0,0 @@
//
// ComposeView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct ComposeView: View {
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@State private var poster: PostService? = nil
@FocusState private var focusedField: FocusableField?
@EnvironmentObject private var controller: ComposeController
var body: some View {
NavigationStack {
navigationRoot
}
}
private var navigationRoot: some View {
ZStack {
ScrollView(.vertical) {
scrollContent
}
.scrollDismissesKeyboard(.interactively)
#if !os(visionOS) && !targetEnvironment(macCatalyst)
.modifier(ToolbarSafeAreaInsetModifier())
#endif
}
.overlay(alignment: .top) {
if let poster {
PostProgressView(poster: poster)
.frame(alignment: .top)
}
}
#if !os(visionOS)
.overlay(alignment: .bottom, content: {
// TODO: during ducking animation, toolbar should move off the botto edge
// This needs to be in an overlay, ignoring the keyboard safe area
// doesn't work with the safeAreaInset modifier.
toolbarView
.frame(maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea(.keyboard)
})
#endif
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarActions(draft: draft, controller: controller)
#if os(visionOS)
ToolbarItem(placement: .bottomOrnament) {
toolbarView
}
#endif
}
}
private var toolbarView: some View {
ComposeToolbarView(draft: draft, mastodonController: mastodonController, focusedField: $focusedField)
}
@ViewBuilder
private var scrollContent: some View {
VStack(spacing: 4) {
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
ContentWarningTextField(draft: draft, focusedField: $focusedField)
NewMainTextView(value: $draft.text, focusedField: $focusedField)
AttachmentsListView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
}
.padding(8)
}
}
private struct NavigationTitleModifier: ViewModifier {
let draft: Draft
let mastodonController: any ComposeMastodonContext
private var navigationTitle: String {
if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) {
return "Reply to @\(status.account.acct)"
} else if draft.editedStatusID != nil {
return "Edit Post"
} else {
return "New Post"
}
}
func body(content: Content) -> some View {
content
.navigationTitle(navigationTitle)
.preference(key: NavigationTitlePreferenceKey.self, value: navigationTitle)
}
}
// Public preference so that the host can read the title.
public struct NavigationTitlePreferenceKey: PreferenceKey {
public static var defaultValue: String? { nil }
public static func reduce(value: inout String?, nextValue: () -> String?) {
value = value ?? nextValue()
}
}
private struct ToolbarActions: ToolbarContent {
@ObservedObject var draft: Draft
// Prior to iOS 16, the toolbar content doesn't seem to have access
// to the environment form the containing view.
let controller: ComposeController
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { draftsButton }
ToolbarItem(placement: .confirmationAction) { postButton }
#else
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
#endif
}
private var draftsButton: some View {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
private var postButton: some View {
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
}
#if !targetEnvironment(macCatalyst)
@ViewBuilder
private var postOrDraftsButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
postButton
} else {
draftsButton
}
}
#endif
}
private struct ToolbarCancelButton: View {
let draft: Draft
@EnvironmentObject private var controller: ComposeController
var body: some View {
Button(action: controller.cancel) {
Text("Cancel")
// otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular))
}
.disabled(controller.isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
}
}
enum FocusableField: Hashable {
case contentWarning
case body
case attachmentDescription(UUID)
var nextField: FocusableField? {
switch self {
case .contentWarning:
return .body
default:
return nil
}
}
}
#if !os(visionOS) && !targetEnvironment(macCatalyst)
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
@StateObject private var keyboardReader = KeyboardReader()
func body(content: Content) -> some View {
if #available(iOS 17.0, *) {
content
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height)
} else {
content
.safeAreaInset(edge: .bottom) {
if !keyboardReader.isVisible {
Color.clear.frame(height: ComposeToolbarView.height)
}
}
}
}
}
#endif
//#Preview {
// ComposeView()
//}

View File

@ -1,38 +0,0 @@
//
// ContentWarningTextField.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct ContentWarningTextField: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
if draft.contentWarningEnabled {
EmojiTextField(
text: $draft.contentWarning,
placeholder: "Write your warning here",
maxLength: nil,
// TODO: completely replace this with FocusState
becomeFirstResponder: .constant(false),
focusNextView: Binding(get: {
false
}, set: {
if $0 {
focusedField = .body
}
})
)
.focused($focusedField, equals: .contentWarning)
.modifier(FocusedInputModifier())
}
}
}
//#Preview {
// ContentWarningTextField()
//}

View File

@ -12,7 +12,6 @@ struct EmojiTextField: UIViewRepresentable {
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.composeInputBox) private var inputBox
@Binding var text: String @Binding var text: String
let placeholder: String let placeholder: String
@ -76,11 +75,7 @@ struct EmojiTextField: UIViewRepresentable {
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
let coordinator = Coordinator(controller: controller, text: $text, focusNextView: focusNextView) Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
DispatchQueue.main.async {
inputBox.wrappedValue = coordinator
}
return coordinator
} }
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput { class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
@ -118,16 +113,12 @@ struct EmojiTextField: UIViewRepresentable {
} }
func textFieldDidBeginEditing(_ textField: UITextField) { func textFieldDidBeginEditing(_ textField: UITextField) {
DispatchQueue.main.async { controller.currentInput = self
self.controller.currentInput = self
}
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
} }
func textFieldDidEndEditing(_ textField: UITextField) { func textFieldDidEndEditing(_ textField: UITextField) {
DispatchQueue.main.async { controller.currentInput = nil
self.controller.currentInput = nil
}
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
} }

View File

@ -29,19 +29,3 @@ struct HeaderView: View {
}.frame(height: 50) }.frame(height: 50)
} }
} }
struct NewHeaderView: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.currentAccount) private var currentAccount
private var charactersRemaining: Int {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var body: some View {
HeaderView(currentAccount: currentAccount, charsRemaining: charactersRemaining)
}
}

View File

@ -1,260 +0,0 @@
//
// NewMainTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/11/24.
//
import SwiftUI
struct NewMainTextView: View {
static var minHeight: CGFloat { 150 }
@Binding var value: String
@FocusState.Binding var focusedField: FocusableField?
@State private var becomeFirstResponder = true
var body: some View {
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder)
.focused($focusedField, equals: .body)
.modifier(FocusedInputModifier())
.overlay(alignment: .topLeading) {
if value.isEmpty {
PlaceholderView()
}
}
}
}
private struct NewMainTextViewRepresentable: UIViewRepresentable {
@Binding var value: String
@Binding var becomeFirstResponder: Bool
@Environment(\.composeInputBox) private var inputBox
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
// TODO: test textSelectionStartsAtBeginning
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
func makeUIView(context: Context) -> UITextView {
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
let view = WrappedTextView(usingTextLayoutManager: true)
view.delegate = context.coordinator
view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping
view.isScrollEnabled = false
view.typingAttributes = [
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
]
view.layer.cornerRadius = 5
view.layer.cornerCurve = .continuous
// view.layer.shadowColor = UIColor.black.cgColor
// view.layer.shadowOpacity = 0.15
// view.layer.shadowOffset = .zero
// view.layer.shadowRadius = 1
if textSelectionStartsAtBeginning {
// Update the text immediately so that the selection isn't invalidated by the text changing.
context.coordinator.updateTextViewTextIfNecessary(value, textView: view)
view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument)
}
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
context.coordinator.value = $value
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
uiView.isEditable = isEnabled
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
#if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
#endif
// Trying to set this with the @FocusState binding in onAppear results in the
// keyboard not appearing until after the sheet presentation animation completes :/
if becomeFirstResponder {
uiView.becomeFirstResponder()
DispatchQueue.main.async {
becomeFirstResponder = false
}
}
}
func makeCoordinator() -> WrappedTextViewCoordinator {
let coordinator = WrappedTextViewCoordinator(value: $value)
// DispatchQueue.main.async {
// inputBox.wrappedValue = coordinator
// }
return coordinator
}
@available(iOS 16.0, *)
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? {
let width = proposal.width ?? 10
let size = uiView.sizeThatFits(CGSize(width: width, height: 0))
return CGSize(width: width, height: max(NewMainTextView.minHeight, size.height))
}
}
// laxer than the CharacterCounter regex, because we want to find mentions that are being typed but aren't yet complete (e.g., "@a@b")
private let mentionRegex = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+)?", options: .caseInsensitive)
private final class WrappedTextViewCoordinator: NSObject {
private static let attachment: NSTextAttachment = {
let font = UIFont.systemFont(ofSize: 20)
let size = /*1.4 * */font.capHeight
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
let image = renderer.image { ctx in
UIColor.systemRed.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
}
let attachment = NSTextAttachment(image: image)
attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size)
attachment.lineLayoutPadding = 1
return attachment
}()
var value: Binding<String>
init(value: Binding<String>) {
self.value = value
}
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
}
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
let str = NSMutableAttributedString(string: text)
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
let range = NSRange(location: match.range.location, length: match.range.length + 1)
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: range)
}
return str
}
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
if text != plainTextFromAttributed(textView.attributedText) {
textView.attributedText = attributedTextFromPlain(text)
}
}
private func updateAttributes(in textView: UITextView) {
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
var changed = false
var cursorOffset = 0
// remove existing mentions that aren't valid
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
var substr = (str.string as NSString).substring(with: range)
let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character)
if hasTextAttachment {
substr = String(substr.dropFirst())
}
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
changed = true
str.removeAttribute(.mention, range: range)
str.removeAttribute(.foregroundColor, range: range)
if hasTextAttachment {
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
cursorOffset -= 1
}
}
}
// add mentions for those missing
let mentionMatches = mentionRegex.matches(in: str.string, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
var attributeRange = NSRange()
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
// the attribute range should always be one greater than the match range, to account for the text attachment
if attribute == nil || attributeRange.length <= match.range.length {
changed = true
let newAttributeRange: NSRange
if attribute == nil {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
cursorOffset += 1
} else {
newAttributeRange = match.range
}
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: newAttributeRange)
}
}
if changed {
let selection = textView.selectedRange
textView.attributedText = str
textView.selectedRange = NSRange(location: selection.location + cursorOffset, length: selection.length)
}
}
}
extension WrappedTextViewCoordinator: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
if textView.text.isEmpty {
textView.typingAttributes = [
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
]
} else {
updateAttributes(in: textView)
}
let plain = plainTextFromAttributed(textView.attributedText)
if plain != value.wrappedValue {
value.wrappedValue = plain
}
}
func textViewDidChangeSelection(_ textView: UITextView) {
}
}
//extension WrappedTextViewCoordinator: ComposeInput {
//
//}
private final class WrappedTextView: UITextView {
}
private extension NSAttributedString.Key {
static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention")
}
private struct PlaceholderView: View {
@State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView()
@ScaledMetric private var fontSize = 20
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
var body: some View {
placeholder
.font(.system(size: fontSize))
.foregroundStyle(.secondary)
.offset(placeholderOffset)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}

View File

@ -1,21 +0,0 @@
//
// PostProgressView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct PostProgressView: View {
@ObservedObject var poster: PostService
var body: some View {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
}
}
//#Preview {
// PostProgressView()
//}

View File

@ -132,21 +132,3 @@ private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepr
} }
} }
} }
struct NewReplyStatusView: View {
let draft: Draft
let mastodonController: any ComposeMastodonContext
var body: some View {
if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) {
ReplyStatusView(
status: status,
rowTopInset: 8,
globalFrameOutsideList: .zero
)
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
.id(id)
}
}
}

View File

@ -9,6 +9,5 @@ import Foundation
public enum FeatureFlag: String, Codable { public enum FeatureFlag: String, Codable {
case iPadBrowserNavigation = "ipad-browser-navigation" case iPadBrowserNavigation = "ipad-browser-navigation"
case composeRewrite = "compose-rewrite"
case pushNotifCustomEmoji = "push-notif-custom-emoji" case pushNotifCustomEmoji = "push-notif-custom-emoji"
} }

View File

@ -1,37 +0,0 @@
//
// PreferenceObserving.swift
// TuskerPreferences
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import Combine
@propertyWrapper
public struct PreferenceObserving<Key: PreferenceKey>: DynamicProperty {
public typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
private let keyPath: PrefKeyPath
@StateObject private var observer: Observer
public init(_ keyPath: PrefKeyPath) {
self.keyPath = keyPath
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
}
public var wrappedValue: Key.Value {
Preferences.shared.getValue(preferenceKeyPath: keyPath)
}
@MainActor
private class Observer: ObservableObject {
private var cancellable: AnyCancellable?
init(keyPath: PrefKeyPath) {
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}

View File

@ -83,8 +83,4 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
func storeCreatedStatus(_ status: Status) { func storeCreatedStatus(_ status: Status) {
} }
func fetchStatus(id: String) -> (any StatusProtocol)? {
return nil
}
} }

View File

@ -59,3 +59,31 @@ private struct AppGroupedListRowBackground: ViewModifier {
} }
} }
} }
@propertyWrapper
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
let keyPath: PrefKeyPath
@StateObject private var observer: Observer
init(_ keyPath: PrefKeyPath) {
self.keyPath = keyPath
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
}
var wrappedValue: Key.Value {
Preferences.shared.getValue(preferenceKeyPath: keyPath)
}
@MainActor
private class Observer: ObservableObject {
private var cancellable: AnyCancellable?
init(keyPath: PrefKeyPath) {
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}

View File

@ -50,10 +50,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) } emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
) )
if let account = mastodonController.account {
controller.currentAccount = account
}
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(rootView: View(mastodonController: mastodonController, controller: controller)) super.init(rootView: View(mastodonController: mastodonController, controller: controller))
@ -133,6 +129,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
let picker = PHPickerViewController(configuration: config) let picker = PHPickerViewController(configuration: config)
picker.delegate = self picker.delegate = self
picker.modalPresentationStyle = .pageSheet picker.modalPresentationStyle = .pageSheet
picker.overrideUserInterfaceStyle = .dark
// sheet detents don't play nice with PHPickerViewController, see // sheet detents don't play nice with PHPickerViewController, see
// let sheet = picker.sheetPresentationController! // let sheet = picker.sheetPresentationController!
// sheet.detents = [.medium(), .large()] // sheet.detents = [.medium(), .large()]
@ -154,8 +151,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
var body: some SwiftUI.View { var body: some SwiftUI.View {
ControllerView(controller: { controller }) ControllerView(controller: { controller })
.task { .task {
if controller.currentAccount == nil, if let account = try? await mastodonController.getOwnAccount() {
let account = try? await mastodonController.getOwnAccount() {
controller.currentAccount = account controller.currentAccount = account
} }
} }
@ -189,7 +185,6 @@ extension ComposeHostingController: DuckableViewController {
} }
#endif #endif
// TODO: don't conform MastodonController to this protocol, use a separate type
extension MastodonController: ComposeMastodonContext { extension MastodonController: ComposeMastodonContext {
@MainActor @MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol] { func searchCachedAccounts(query: String) -> [AccountProtocol] {
@ -232,10 +227,6 @@ extension MastodonController: ComposeMastodonContext {
func storeCreatedStatus(_ status: Status) { func storeCreatedStatus(_ status: Status) {
persistentContainer.addOrUpdate(status: status) persistentContainer.addOrUpdate(status: status)
} }
func fetchStatus(id: String) -> (any StatusProtocol)? {
return persistentContainer.status(for: id)
}
} }
extension ComposeHostingController: PHPickerViewControllerDelegate { extension ComposeHostingController: PHPickerViewControllerDelegate {

View File

@ -18,6 +18,7 @@ struct AdvancedPrefsView : View {
@State private var mastodonCacheSize: Int64 = 0 @State private var mastodonCacheSize: Int64 = 0
@State private var cloudKitStatus: CKAccountStatus? @State private var cloudKitStatus: CKAccountStatus?
@State private var isShowingFeatureFlagAlert = false @State private var isShowingFeatureFlagAlert = false
@State private var featureFlagName = ""
var body: some View { var body: some View {
List { List {
@ -31,9 +32,23 @@ struct AdvancedPrefsView : View {
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.appGroupedListBackground(container: PreferencesNavigationController.self) .appGroupedListBackground(container: PreferencesNavigationController.self)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
featureFlagName = ""
isShowingFeatureFlagAlert = true isShowingFeatureFlagAlert = true
} }
.modifier(FeatureFlagAlertModifier(showing: $isShowingFeatureFlagAlert)) .alert("Enable Feature Flag", isPresented: $isShowingFeatureFlagAlert) {
TextField("Flag Name", text: $featureFlagName)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {}
Button("Enable") {
if let flag = FeatureFlag(rawValue: featureFlagName) {
preferences.enabledFeatureFlags.insert(flag)
}
}
} message: {
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
}
.navigationBarTitle(Text("Advanced")) .navigationBarTitle(Text("Advanced"))
} }
@ -237,74 +252,6 @@ extension StatusContentType {
} }
} }
private struct FeatureFlagAlertModifier: ViewModifier {
@Binding var showing: Bool
@ObservedObject private var preferences = Preferences.shared
@State private var featureFlagName = ""
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content
.onChange(of: showing) {
if $0 {
featureFlagName = ""
}
}
.alert("Enable Feature Flag", isPresented: $showing) {
textField
Button("Cancel", role: .cancel) {}
Button("Enable", action: enableFlag)
} message: {
warning
}
} else {
content
.sheet(isPresented: $showing) {
NavigationView {
List {
Section {
textField
} footer: {
warning
}
}
.navigationTitle("Enable Feature Flag")
.navigationBarTitleDisplayMode(.inline)
.listStyle(.insetGrouped)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
showing = false
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Enable", action: self.enableFlag)
}
}
}
.navigationViewStyle(.stack)
}
}
}
private var textField: some View {
TextField("Flag Name", text: $featureFlagName)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
private var warning: Text {
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
}
private func enableFlag() {
if let flag = FeatureFlag(rawValue: featureFlagName) {
preferences.enabledFeatureFlags.insert(flag)
showing = false
}
}
}
#if DEBUG #if DEBUG
struct AdvancedPrefsView_Previews : PreviewProvider { struct AdvancedPrefsView_Previews : PreviewProvider {
static var previews: some View { static var previews: some View {

View File

@ -30,7 +30,9 @@ class AccountDisplayAndUserNameLabel: EmojiLabel {
private func makeAttributedText(state: State) -> NSAttributedString { private func makeAttributedText(state: State) -> NSAttributedString {
let s = NSMutableAttributedString() let s = NSMutableAttributedString()
s.append(NSAttributedString(string: state.displayName, attributes: [ // U+2068 FIRST-STRONG ISOLATE and U+2069 POP DIRECTIONAL ISOLATE
// to prevent bidi text in the display name influencing the username
s.append(NSAttributedString(string: "\u{2068}\(state.displayName)\u{2069}", attributes: [
.font: UIFont(descriptor: baseFont.addingAttributes([ .font: UIFont(descriptor: baseFont.addingAttributes([
.traits: [ .traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue, UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,