diff --git a/Packages/ComposeUI/Package.swift b/Packages/ComposeUI/Package.swift index adbd2bba..07e311ac 100644 --- a/Packages/ComposeUI/Package.swift +++ b/Packages/ComposeUI/Package.swift @@ -20,13 +20,14 @@ let package = Package( .package(path: "../InstanceFeatures"), .package(path: "../TuskerComponents"), .package(path: "../MatchedGeometryPresentation"), + .package(path: "../TuskerPreferences"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "ComposeUI", - dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]), + dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences"]), .testTarget( name: "ComposeUITests", dependencies: ["ComposeUI"]), diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index c7de164d..762a5053 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -11,7 +11,7 @@ import Pachyderm import UniformTypeIdentifiers @MainActor -class PostService: ObservableObject { +final class PostService: ObservableObject { private let mastodonController: ComposeMastodonContext private let config: ComposeUIConfig private let draft: Draft diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift index 35a180e8..b1d42f03 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift @@ -8,6 +8,7 @@ import Foundation import Combine import UIKit +import SwiftUI protocol ComposeInput: AnyObject, ObservableObject { var toolbarElements: [ToolbarElement] { get } @@ -27,3 +28,50 @@ enum ToolbarElement { case emojiPicker 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: 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) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift index f1d0b05a..3a0952e9 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift @@ -9,6 +9,7 @@ import Foundation import Pachyderm import InstanceFeatures import UserAccounts +import SwiftUI public protocol ComposeMastodonContext { var accountInfo: UserAccountInfo? { get } @@ -26,4 +27,6 @@ public protocol ComposeMastodonContext { func searchCachedHashtags(query: String) -> [Hashtag] func storeCreatedStatus(_ status: Status) + + func fetchStatus(id: String) -> (any StatusProtocol)? } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index da478b5b..dd8eb06f 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -132,11 +132,16 @@ public final class ComposeController: ViewController { } public var view: some View { - ComposeView(poster: poster) - .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) - .environmentObject(draft) - .environmentObject(mastodonController.instanceFeatures) - .environment(\.composeUIConfig, config) + if Preferences.shared.hasFeatureFlag(.composeRewrite) { + ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController) + .environment(\.currentAccount, currentAccount) + } else { + ComposeView(poster: poster) + .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) + .environmentObject(draft) + .environmentObject(mastodonController.instanceFeatures) + .environment(\.composeUIConfig, config) + } } @MainActor @@ -503,7 +508,7 @@ public final class ComposeController: ViewController { } } -private extension View { +extension View { @available(iOS, obsoleted: 16.0) @ViewBuilder func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Environment.swift b/Packages/ComposeUI/Sources/ComposeUI/Environment.swift new file mode 100644 index 00000000..c91331a2 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Environment.swift @@ -0,0 +1,49 @@ +// +// Environment.swift +// ComposeUI +// +// Created by Shadowfacts on 8/10/24. +// + +import SwiftUI +import Pachyderm + +//@propertyWrapper +//struct RequiredEnvironment: DynamicProperty { +// private let keyPath: KeyPath +// @Environment private var value: Value? +// +// init(_ keyPath: KeyPath) { +// 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 } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift index 11aa3771..5625d2ef 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift @@ -10,9 +10,7 @@ import UIKit import Combine -@available(iOS, obsoleted: 16.0) class KeyboardReader: ObservableObject { -// @Published var isVisible = false @Published var keyboardHeight: CGFloat = 0 var isVisible: Bool { @@ -27,14 +25,12 @@ class KeyboardReader: ObservableObject { @objc func willShow(_ notification: Foundation.Notification) { let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect -// isVisible = endFrame.height > 72 keyboardHeight = endFrame.height } @objc func willHide() { // sometimes willHide is called during a SwiftUI view update DispatchQueue.main.async { -// self.isVisible = false self.keyboardHeight = 0 } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift index af57c88d..a7f3a6bf 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift @@ -9,9 +9,13 @@ import UIKit import Pachyderm -enum StatusFormat: Int, CaseIterable { +enum StatusFormat: Int, CaseIterable, Identifiable { case bold, italics, strikethrough, code + var id: some Hashable { + rawValue + } + func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? { switch contentType { case .plain: diff --git a/Packages/ComposeUI/Sources/ComposeUI/Preferences.swift b/Packages/ComposeUI/Sources/ComposeUI/Preferences.swift new file mode 100644 index 00000000..3a3a2a8e --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Preferences.swift @@ -0,0 +1,11 @@ +// +// Preferences.swift +// ComposeUI +// +// Created by Shadowfacts on 8/10/24. +// + +import Foundation +import TuskerPreferences + +typealias Preferences = TuskerPreferences.Preferences diff --git a/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift index b08d953e..e3c41481 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift @@ -11,6 +11,7 @@ import Combine public protocol ViewController: ObservableObject { associatedtype ContentView: View + @MainActor @ViewBuilder var view: ContentView { get } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift new file mode 100644 index 00000000..e87a292f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift @@ -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: 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 { + // 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.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, mastodonController: any ComposeMastodonContext) { + self._enabled = enabled + self.mastodonController = mastodonController + self.instanceFeatures = mastodonController.instanceFeatures + } + + private var options: [MenuPicker.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() +//} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift new file mode 100644 index 00000000..d2da03b1 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -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() +//} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ContentWarningTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ContentWarningTextField.swift new file mode 100644 index 00000000..651f5da4 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ContentWarningTextField.swift @@ -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() +//} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift index ef856abe..fc710ced 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift @@ -12,6 +12,7 @@ struct EmojiTextField: UIViewRepresentable { @EnvironmentObject private var controller: ComposeController @Environment(\.colorScheme) private var colorScheme + @Environment(\.composeInputBox) private var inputBox @Binding var text: String let placeholder: String @@ -75,7 +76,11 @@ struct EmojiTextField: UIViewRepresentable { } 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 { @@ -113,12 +118,16 @@ struct EmojiTextField: UIViewRepresentable { } func textFieldDidBeginEditing(_ textField: UITextField) { - controller.currentInput = self + DispatchQueue.main.async { + self.controller.currentInput = self + } autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) } func textFieldDidEndEditing(_ textField: UITextField) { - controller.currentInput = nil + DispatchQueue.main.async { + self.controller.currentInput = nil + } autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift index 3c57890a..628ae9e4 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift @@ -29,3 +29,19 @@ struct HeaderView: View { }.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) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PostProgressView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PostProgressView.swift new file mode 100644 index 00000000..3a8eeeb7 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PostProgressView.swift @@ -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() +//} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift index c6198897..eb46e382 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift @@ -132,3 +132,21 @@ private struct AvatarContainerRepresentable: 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) + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/SwiftUI/PreferenceObserving.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/SwiftUI/PreferenceObserving.swift new file mode 100644 index 00000000..d49206db --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/SwiftUI/PreferenceObserving.swift @@ -0,0 +1,37 @@ +// +// PreferenceObserving.swift +// TuskerPreferences +// +// Created by Shadowfacts on 8/10/24. +// + +import SwiftUI +import Combine + +@propertyWrapper +public struct PreferenceObserving: DynamicProperty { + public typealias PrefKeyPath = KeyPath> + + 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() + } + } + } +} diff --git a/ShareExtension/ShareMastodonContext.swift b/ShareExtension/ShareMastodonContext.swift index 18b08c28..29c28cfe 100644 --- a/ShareExtension/ShareMastodonContext.swift +++ b/ShareExtension/ShareMastodonContext.swift @@ -83,4 +83,8 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send func storeCreatedStatus(_ status: Status) { } + + func fetchStatus(id: String) -> (any StatusProtocol)? { + return nil + } } diff --git a/Tusker/Extensions/View+AppListStyle.swift b/Tusker/Extensions/View+AppListStyle.swift index 472bfc3c..aca1b218 100644 --- a/Tusker/Extensions/View+AppListStyle.swift +++ b/Tusker/Extensions/View+AppListStyle.swift @@ -66,31 +66,3 @@ private struct AppGroupedListRowBackground: ViewModifier { } } } - -@propertyWrapper -private struct PreferenceObserving: DynamicProperty { - typealias PrefKeyPath = KeyPath> - - 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() - } - } - } -} diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index a98e17de..32996a8b 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -190,6 +190,7 @@ extension ComposeHostingController: DuckableViewController { } #endif +// TODO: don't conform MastodonController to this protocol, use a separate type extension MastodonController: ComposeMastodonContext { @MainActor func searchCachedAccounts(query: String) -> [AccountProtocol] { @@ -232,6 +233,10 @@ extension MastodonController: ComposeMastodonContext { func storeCreatedStatus(_ status: Status) { persistentContainer.addOrUpdate(status: status) } + + func fetchStatus(id: String) -> (any StatusProtocol)? { + return persistentContainer.status(for: id) + } } extension ComposeHostingController: PHPickerViewControllerDelegate {