WIP compose rewrite

This commit is contained in:
Shadowfacts 2024-08-11 10:14:10 -07:00
parent 66626c8f62
commit 198b201a51
21 changed files with 776 additions and 44 deletions

View File

@ -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"]),

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)?
} }

View File

@ -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 {

View File

@ -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 }
}
}

View File

@ -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
} }
} }

View File

@ -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:

View File

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

View File

@ -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 }
} }

View File

@ -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()
//}

View File

@ -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()
//}

View File

@ -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()
//}

View File

@ -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)
} }

View File

@ -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)
}
}

View File

@ -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()
//}

View File

@ -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)
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -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
}
} }

View File

@ -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()
}
}
}
}

View File

@ -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 {