WIP compose rewrite
This commit is contained in:
parent
66626c8f62
commit
198b201a51
|
@ -20,13 +20,14 @@ 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"]),
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences"]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ComposeUITests",
|
name: "ComposeUITests",
|
||||||
dependencies: ["ComposeUI"]),
|
dependencies: ["ComposeUI"]),
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class PostService: ObservableObject {
|
final 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
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
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 }
|
||||||
|
@ -27,3 +28,50 @@ enum ToolbarElement {
|
||||||
case emojiPicker
|
case emojiPicker
|
||||||
case formattingButtons
|
case formattingButtons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct FocusedComposeInput: FocusedValueKey {
|
||||||
|
typealias Value = (any ComposeInput)?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FocusedValues {
|
||||||
|
// This double optional is unfortunate, but avoiding it requires iOS 16 API
|
||||||
|
fileprivate var _composeInput: (any ComposeInput)?? {
|
||||||
|
get { self[FocusedComposeInput.self] }
|
||||||
|
set { self[FocusedComposeInput.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var composeInput: (any ComposeInput)? {
|
||||||
|
get { _composeInput ?? nil }
|
||||||
|
set { _composeInput = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ 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 }
|
||||||
|
@ -26,4 +27,6 @@ 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)?
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,11 +132,16 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var view: some View {
|
public var view: some View {
|
||||||
ComposeView(poster: poster)
|
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
|
||||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
|
||||||
.environmentObject(draft)
|
.environment(\.currentAccount, currentAccount)
|
||||||
.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
|
||||||
|
@ -503,7 +508,7 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension View {
|
extension View {
|
||||||
@available(iOS, obsoleted: 16.0)
|
@available(iOS, obsoleted: 16.0)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
//
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,9 +10,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
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 {
|
||||||
|
@ -27,14 +25,12 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,9 +9,13 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
enum StatusFormat: Int, CaseIterable {
|
enum StatusFormat: Int, CaseIterable, Identifiable {
|
||||||
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:
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
//
|
||||||
|
// Preferences.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
typealias Preferences = TuskerPreferences.Preferences
|
|
@ -11,6 +11,7 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,273 @@
|
||||||
|
//
|
||||||
|
// 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.contentWarningEnabled, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDisabledIfAvailable(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 #available(iOS 16.0, *),
|
||||||
|
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()
|
||||||
|
//}
|
|
@ -0,0 +1,221 @@
|
||||||
|
//
|
||||||
|
// 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?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
NavigationStack {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var navigationRoot: some View {
|
||||||
|
ZStack {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
scrollContent
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
|
#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)
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
@EnvironmentObject private var 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()
|
||||||
|
//}
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
//}
|
|
@ -12,6 +12,7 @@ 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
|
||||||
|
@ -75,7 +76,11 @@ struct EmojiTextField: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
let coordinator = 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 {
|
||||||
|
@ -113,12 +118,16 @@ struct EmojiTextField: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||||
controller.currentInput = self
|
DispatchQueue.main.async {
|
||||||
|
self.controller.currentInput = self
|
||||||
|
}
|
||||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||||
controller.currentInput = nil
|
DispatchQueue.main.async {
|
||||||
|
self.controller.currentInput = nil
|
||||||
|
}
|
||||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,3 +29,19 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
//}
|
|
@ -132,3 +132,21 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -83,4 +83,8 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
|
||||||
|
|
||||||
func storeCreatedStatus(_ status: Status) {
|
func storeCreatedStatus(_ status: Status) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,31 +66,3 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -190,6 +190,7 @@ 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,6 +233,10 @@ 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 {
|
||||||
|
|
Loading…
Reference in New Issue