Compare commits

...

4 Commits

22 changed files with 343 additions and 204 deletions

View File

@ -12,20 +12,20 @@ import UniformTypeIdentifiers
@MainActor @MainActor
final class PostService: ObservableObject { final class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext private let mastodonController: any ComposeMastodonContext
private let config: ComposeUIConfig private let config: ComposeUIConfig
private let draft: Draft private let draft: Draft
@Published var currentStep = 1 @Published var currentStep = 1
@Published private(set) var totalSteps = 2 @Published private(set) var totalSteps = 2
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) { init(mastodonController: any ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.config = config self.config = config
self.draft = draft self.draft = draft
} }
func post() async throws { func post() async throws(Error) {
guard draft.hasContent || draft.editedStatusID != nil else { guard draft.hasContent || draft.editedStatusID != nil else {
return return
} }
@ -106,12 +106,12 @@ final class PostService: ObservableObject {
let (status, _) = try await mastodonController.run(request) let (status, _) = try await mastodonController.run(request)
currentStep += 1 currentStep += 1
mastodonController.storeCreatedStatus(status) mastodonController.storeCreatedStatus(status)
} catch let error as Client.Error { } catch {
throw Error.posting(error) throw Error.posting(error)
} }
} }
private func uploadAttachments() async throws -> [String] { private func uploadAttachments() async throws(Error) -> [String] {
// 2 steps (request data, then upload) for each attachment // 2 steps (request data, then upload) for each attachment
self.totalSteps += 2 * draft.attachments.count self.totalSteps += 2 * draft.attachments.count
@ -131,7 +131,7 @@ final class PostService: ObservableObject {
do { do {
(data, utType) = try await getData(for: attachment) (data, utType) = try await getData(for: attachment)
currentStep += 1 currentStep += 1
} catch let error as DraftAttachment.ExportError { } catch {
throw Error.attachmentData(index: index, cause: error) throw Error.attachmentData(index: index, cause: error)
} }
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription) let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
@ -141,20 +141,21 @@ final class PostService: ObservableObject {
return attachments return attachments
} }
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) { private func getData(for attachment: DraftAttachment) async throws(DraftAttachment.ExportError) -> (Data, UTType) {
return try await withCheckedThrowingContinuation { continuation in let result = await withCheckedContinuation { continuation in
attachment.getData(features: mastodonController.instanceFeatures) { result in attachment.getData(features: mastodonController.instanceFeatures) { result in
continuation.resume(returning: result)
}
}
switch result { switch result {
case let .success(res): case .success(let result):
continuation.resume(returning: res) return result
case let .failure(error): case .failure(let error):
continuation.resume(throwing: error) throw error
}
}
} }
} }
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment { private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws(Error) -> Attachment {
guard let mimeType = utType.preferredMIMEType else { guard let mimeType = utType.preferredMIMEType else {
throw Error.attachmentMissingMimeType(index: index, type: utType) throw Error.attachmentMissingMimeType(index: index, type: utType)
} }
@ -166,7 +167,7 @@ final class PostService: ObservableObject {
let req = Client.upload(attachment: formAttachment, description: description) let req = Client.upload(attachment: formAttachment, description: description)
do { do {
return try await mastodonController.run(req).0 return try await mastodonController.run(req).0
} catch let error as Client.Error { } catch {
throw Error.attachmentUpload(index: index, cause: error) throw Error.attachmentUpload(index: index, cause: error)
} }
} }

View File

@ -15,7 +15,7 @@ public protocol ComposeMastodonContext {
var accountInfo: UserAccountInfo? { get } var accountInfo: UserAccountInfo? { get }
var instanceFeatures: InstanceFeatures { get } var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) func run<Result: Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?)
func getCustomEmojis() async -> [Emoji] func getCustomEmojis() async -> [Emoji]

View File

@ -11,10 +11,12 @@ import PhotosUI
import PencilKit import PencilKit
import TuskerComponents import TuskerComponents
// Configuration/data injected from outside the compose UI.
public struct ComposeUIConfig { public struct ComposeUIConfig {
// Config // Config
public var allowSwitchingDrafts = true public var allowSwitchingDrafts = true
public var textSelectionStartsAtBeginning = false public var textSelectionStartsAtBeginning = false
public var showToolbar = true
// Style // Style
public var backgroundColor = Color(uiColor: .systemBackground) public var backgroundColor = Color(uiColor: .systemBackground)
@ -33,6 +35,9 @@ public struct ComposeUIConfig {
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)? public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil } public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil }
public var fetchAvatar: AvatarImageView.FetchAvatar = { _ in nil }
public var displayNameLabel: (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView = { _, _, _ in AnyView(EmptyView()) }
public var replyContentView: (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView = { _, _ in AnyView(EmptyView()) }
public init() { public init() {
} }

View File

@ -132,18 +132,12 @@ public final class ComposeController: ViewController {
} }
public var view: some View { public var view: some View {
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
.environment(\.currentAccount, currentAccount)
.environment(\.composeUIConfig, config)
} else {
ComposeView(poster: poster) ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft) .environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures) .environmentObject(mastodonController.instanceFeatures)
.environment(\.composeUIConfig, config) .environment(\.composeUIConfig, config)
} }
}
@MainActor @MainActor
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {

View File

@ -70,7 +70,15 @@ public class Draft: NSManagedObject, Identifiable {
} }
extension Draft { extension Draft {
public var hasContent: Bool { var hasText: Bool {
!text.isEmpty && text != initialText !text.isEmpty && text != initialText
} }
var hasContentWarning: Bool {
contentWarningEnabled && contentWarning != initialContentWarning
}
public var hasContent: Bool {
hasText || hasContentWarning || attachments.count > 0 || (pollEnabled && poll!.hasContent)
}
} }

View File

@ -12,7 +12,7 @@ import Pachyderm
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
public class DraftsPersistentContainer: NSPersistentContainer { public final class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer() public static let shared = DraftsPersistentContainer()

View File

@ -64,7 +64,12 @@ struct AttachmentsGalleryDataSource: GalleryDataSource {
} }
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? { func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
collectionView.cellForItem(at: IndexPath(item: index, section: 0)) if let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? HostingCollectionViewCell {
// Use the hostView, because otherwise, the animation's changes to the source view opacity get clobbered by SwiftUI
cell.hostView
} else {
nil
}
} }
private func fetchImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? { private func fetchImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? {

View File

@ -363,7 +363,7 @@ private final class IntrinsicContentSizeCollectionView: UICollectionView {
} }
#if os(visionOS) #if os(visionOS)
private class HostingCollectionViewCell: UICollectionViewCell { final class HostingCollectionViewCell: UICollectionViewCell {
private(set) var hostView: UIView? private(set) var hostView: UIView?
func setView<V: View>(_ view: V) { func setView<V: View>(_ view: V) {
@ -382,7 +382,7 @@ private class HostingCollectionViewCell: UICollectionViewCell {
} }
#else #else
@available(iOS, obsoleted: 16.0) @available(iOS, obsoleted: 16.0)
private class HostingCollectionViewCell: UICollectionViewCell { final class HostingCollectionViewCell: UICollectionViewCell {
weak var containingViewController: UIViewController? weak var containingViewController: UIViewController?
@available(iOS, obsoleted: 16.0) @available(iOS, obsoleted: 16.0)

View File

@ -11,49 +11,67 @@ import InstanceFeatures
struct ComposeNavigationBarActions: ToolbarContent { struct ComposeNavigationBarActions: ToolbarContent {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
// Prior to iOS 16, the toolbar content doesn't seem to have access
// to the environment from the containing view.
let controller: ComposeController
@Binding var isShowingDrafts: Bool @Binding var isShowingDrafts: Bool
let isPosting: Bool let isPosting: Bool
let cancel: (_ deleteDraft: Bool) -> Void
let postStatus: () async -> Void
var body: some ToolbarContent { var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) } ToolbarItem(placement: .cancellationAction) {
ToolbarCancelButton(draft: draft, isPosting: isPosting, cancel: cancel)
}
#if targetEnvironment(macCatalyst) #if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { DraftsButton(isShowingDrafts: $isShowingDrafts) } ToolbarItem(placement: .topBarTrailing) {
ToolbarItem(placement: .confirmationAction) { PostButton(draft: draft, isPosting: isPosting) } DraftsButton(isShowingDrafts: $isShowingDrafts)
}
ToolbarItem(placement: .confirmationAction) {
PostButton(draft: draft, isPosting: isPosting)
}
#else #else
ToolbarItem(placement: .confirmationAction) { PostOrDraftsButton(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: isPosting) } ToolbarItem(placement: .confirmationAction) {
PostOrDraftsButton(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: isPosting, postStatus: postStatus)
}
#endif #endif
} }
} }
private struct ToolbarCancelButton: View { private struct ToolbarCancelButton: View {
let draft: Draft let draft: Draft
let isPosting: Bool
let cancel: (_ deleteDraft: Bool) -> Void
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
@State private var isShowingSaveDraftSheet = false
var body: some View { var body: some View {
Button(role: .cancel, action: controller.cancel) { Button(role: .cancel, action: self.showConfirmationOrCancel) {
Text("Cancel") Text("Cancel")
} }
.disabled(controller.isPosting) .disabled(isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) { .confirmationDialog("Are you sure?", isPresented: $isShowingSaveDraftSheet) {
// edit drafts can't be saved // edit drafts can't be saved
if draft.editedStatusID == nil { if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) { Button(action: { cancel(false) }) {
Text("Save Draft") Text("Save Draft")
} }
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) { Button(role: .destructive, action: { cancel(true) }) {
Text("Delete Draft") Text("Delete Draft")
} }
} else { } else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) { Button(role: .destructive, action: { cancel(true) }) {
Text("Cancel Edit") Text("Cancel Edit")
} }
} }
} }
} }
private func showConfirmationOrCancel() {
if draft.hasContent {
isShowingSaveDraftSheet = true
} else {
cancel(true)
}
}
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
@ -61,11 +79,12 @@ private struct PostOrDraftsButton: View {
@DraftObserving var draft: Draft @DraftObserving var draft: Draft
@Binding var isShowingDrafts: Bool @Binding var isShowingDrafts: Bool
let isPosting: Bool let isPosting: Bool
let postStatus: () async -> Void
@Environment(\.composeUIConfig.allowSwitchingDrafts) private var allowSwitchingDrafts @Environment(\.composeUIConfig.allowSwitchingDrafts) private var allowSwitchingDrafts
var body: some View { var body: some View {
if !draftIsEmpty || draft.editedStatusID != nil || !allowSwitchingDrafts { if !draftIsEmpty || draft.editedStatusID != nil || !allowSwitchingDrafts {
PostButton(draft: draft, isPosting: isPosting) PostButton(draft: draft, isPosting: isPosting, postStatus: postStatus)
} else { } else {
DraftsButton(isShowingDrafts: $isShowingDrafts) DraftsButton(isShowingDrafts: $isShowingDrafts)
} }
@ -80,12 +99,17 @@ private struct PostOrDraftsButton: View {
private struct PostButton: View { private struct PostButton: View {
@DraftObserving var draft: Draft @DraftObserving var draft: Draft
let isPosting: Bool let isPosting: Bool
let postStatus: () async -> Void
@EnvironmentObject private var instanceFeatures: InstanceFeatures @EnvironmentObject private var instanceFeatures: InstanceFeatures
@Environment(\.composeUIConfig.requireAttachmentDescriptions) private var requireAttachmentDescriptions @Environment(\.composeUIConfig.requireAttachmentDescriptions) private var requireAttachmentDescriptions
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
var body: some View { var body: some View {
Button(action: controller.postStatus) { Button {
Task {
await postStatus()
}
} label: {
Text(draft.editedStatusID == nil ? "Post" : "Edit") Text(draft.editedStatusID == nil ? "Post" : "Edit")
} }
.keyboardShortcut(.return, modifiers: .command) .keyboardShortcut(.return, modifiers: .command)
@ -125,7 +149,7 @@ private struct PostButton: View {
private var draftValid: Bool { private var draftValid: Bool {
draft.editedStatusID != nil || draft.editedStatusID != nil ||
((draft.hasContent || draft.attachments.count > 0) ((draft.hasText || draft.attachments.count > 0)
&& hasCharactersRemaining && hasCharactersRemaining
&& attachmentsValid && attachmentsValid
&& pollValid) && pollValid)

View File

@ -7,21 +7,79 @@
import SwiftUI import SwiftUI
import CoreData import CoreData
import Pachyderm
struct ComposeView: View { // State owned by the compose UI but that needs to be accessible from outside.
@ObservedObject var draft: Draft public final class ComposeViewState: ObservableObject {
let mastodonController: any ComposeMastodonContext @Published var poster: PostService?
// @State private var poster: PostService? = nil @Published public internal(set) var draft: Draft
@FocusState private var focusedField: FocusableField? @Published public internal(set) var didPostSuccessfully = false
@EnvironmentObject private var controller: ComposeController
@State private var isShowingDrafts = false
// TODO: replace this with an @State owned by this view public var isPosting: Bool {
var poster: PostService? { poster != nil
controller.poster
} }
var body: some View { public init(draft: Draft) {
self.draft = draft
}
}
public struct ComposeView: View {
@ObservedObject var state: ComposeViewState
let mastodonController: any ComposeMastodonContext
let currentAccount: (any AccountProtocol)?
let config: ComposeUIConfig
public init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
currentAccount: (any AccountProtocol)?,
config: ComposeUIConfig
) {
self.state = state
self.mastodonController = mastodonController
self.currentAccount = currentAccount
self.config = config
}
public var body: some View {
ComposeViewBody(
draft: state.draft,
mastodonController: mastodonController,
state: state,
setDraft: self.setDraft
)
.environment(\.composeUIConfig, config)
.environment(\.currentAccount, currentAccount)
}
private func setDraft(_ draft: Draft) {
let oldDraft = state.draft
state.draft = draft
if oldDraft.hasContent {
oldDraft.lastModified = Date()
} else {
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
}
DraftsPersistentContainer.shared.save()
}
}
// TODO: see if this can be broken up further
private struct ComposeViewBody: View {
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@ObservedObject var state: ComposeViewState
let setDraft: (Draft) -> Void
@State private var postError: PostService.Error?
@FocusState private var focusedField: FocusableField?
@State private var isShowingDrafts = false
@State private var isDismissing = false
@State private var userConfirmedDelete = false
@Environment(\.composeUIConfig) private var config
public var body: some View {
navigation navigation
.environmentObject(mastodonController.instanceFeatures) .environmentObject(mastodonController.instanceFeatures)
.sheet(isPresented: $isShowingDrafts) { .sheet(isPresented: $isShowingDrafts) {
@ -29,9 +87,13 @@ struct ComposeView: View {
currentDraft: draft, currentDraft: draft,
isShowingDrafts: $isShowingDrafts, isShowingDrafts: $isShowingDrafts,
accountInfo: mastodonController.accountInfo!, accountInfo: mastodonController.accountInfo!,
selectDraft: self.selectDraft selectDraft: {
self.setDraft($0)
self.isShowingDrafts = false
}
) )
} }
.onDisappear(perform: self.deleteOrSaveDraft)
} }
@ViewBuilder @ViewBuilder
@ -65,19 +127,22 @@ struct ComposeView: View {
#endif #endif
} }
.overlay(alignment: .top) { .overlay(alignment: .top) {
if let poster { if let poster = state.poster {
PostProgressView(poster: poster) PostProgressView(poster: poster)
.frame(alignment: .top) .frame(alignment: .top)
} }
} }
#if !os(visionOS) #if !os(visionOS)
.overlay(alignment: .bottom, content: { .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 // This needs to be in an overlay, ignoring the keyboard safe area
// doesn't work with the safeAreaInset modifier. // doesn't work with the safeAreaInset modifier.
if config.showToolbar {
toolbarView toolbarView
.frame(maxHeight: .infinity, alignment: .bottom) .frame(maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom))
.animation(.snappy, value: config.showToolbar)
}
}) })
#endif #endif
// Have these after the overlays so they barely work instead of not working at all. FB11790805 // Have these after the overlays so they barely work instead of not working at all. FB11790805
@ -86,7 +151,7 @@ struct ComposeView: View {
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ComposeNavigationBarActions(draft: draft, controller: controller, isShowingDrafts: $isShowingDrafts, isPosting: poster != nil) ComposeNavigationBarActions(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: state.isPosting, cancel: self.cancel(deleteDraft:), postStatus: self.postStatus)
#if os(visionOS) #if os(visionOS)
ToolbarItem(placement: .bottomOrnament) { ToolbarItem(placement: .bottomOrnament) {
toolbarView toolbarView
@ -113,17 +178,52 @@ struct ComposeView: View {
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders) AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
} }
private func selectDraft(_ draft: Draft) { private func deleteOrSaveDraft() {
controller.selectDraft(draft) if isDismissing,
isShowingDrafts = false !draft.hasContent || state.didPostSuccessfully || userConfirmedDelete {
DraftsPersistentContainer.shared.viewContext.delete(draft)
} else {
draft.lastModified = Date()
}
DraftsPersistentContainer.shared.save()
}
private func cancel(deleteDraft: Bool) {
isDismissing = true
userConfirmedDelete = deleteDraft
config.dismiss(.cancel)
}
private func postStatus() async {
guard !state.isPosting,
draft.editedStatusID != nil || draft.hasContent else {
return
}
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
state.poster = poster
do {
try await poster.post()
isDismissing = true
state.didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed
try? await Task.sleep(nanoseconds: 250_000_000)
// don't unset the poster, so the ui remains disabled while dismissing
config.dismiss(.post)
} catch {
self.postError = error
state.poster = nil
}
} }
} }
private struct NavigationTitleModifier: ViewModifier { extension ComposeView {
let draft: Draft public static func navigationTitle(for draft: Draft, mastodonController: any ComposeMastodonContext) -> String {
let mastodonController: any ComposeMastodonContext
private var navigationTitle: String {
if let id = draft.inReplyToID, if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) { let status = mastodonController.fetchStatus(id: id) {
return "Reply to @\(status.account.acct)" return "Reply to @\(status.account.acct)"
@ -133,19 +233,16 @@ private struct NavigationTitleModifier: ViewModifier {
return "New Post" 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. private struct NavigationTitleModifier: ViewModifier {
public struct NavigationTitlePreferenceKey: PreferenceKey { let draft: Draft
public static var defaultValue: String? { nil } let mastodonController: any ComposeMastodonContext
public static func reduce(value: inout String?, nextValue: () -> String?) {
value = value ?? nextValue() func body(content: Content) -> some View {
let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
content
.navigationTitle(title)
} }
} }

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import TuskerComponents import TuskerComponents
import TuskerPreferences
struct DraftEditor: View { struct DraftEditor: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@ -55,15 +56,15 @@ struct DraftEditor: View {
private struct AvatarView: View { private struct AvatarView: View {
let account: (any AccountProtocol)? let account: (any AccountProtocol)?
@Environment(\.composeUIConfig.avatarStyle) private var avatarStyle @PreferenceObserving(\.$avatarStyle) private var avatarStyle
@EnvironmentObject private var controller: ComposeController @Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
var body: some View { var body: some View {
AvatarImageView( AvatarImageView(
url: account?.avatar, url: account?.avatar,
size: 50, size: 50,
style: avatarStyle, style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: controller.fetchAvatar fetchAvatar: fetchAvatar
) )
.accessibilityHidden(true) .accessibilityHidden(true)
} }
@ -71,11 +72,11 @@ private struct AvatarView: View {
private struct AccountNameView: View { private struct AccountNameView: View {
let account: any AccountProtocol let account: any AccountProtocol
@EnvironmentObject private var controller: ComposeController @Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
var body: some View { var body: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
controller.displayNameLabel(account, .body, 16) displayNameLabel(account, .body, 16)
.lineLimit(1) .lineLimit(1)
Text(verbatim: "@\(account.acct)") Text(verbatim: "@\(account.acct)")

View File

@ -10,7 +10,7 @@ import SwiftUI
struct EmojiTextField: UIViewRepresentable { struct EmojiTextField: UIViewRepresentable {
typealias UIViewType = UITextField typealias UIViewType = UITextField
@EnvironmentObject private var controller: ComposeController @Environment(\.composeUIConfig.fillColor) private var fillColor
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.composeInputBox) private var inputBox @Environment(\.composeInputBox) private var inputBox
@ -64,7 +64,7 @@ struct EmojiTextField: UIViewRepresentable {
context.coordinator.focusNextView = focusNextView context.coordinator.focusNextView = focusNextView
#if !os(visionOS) #if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
#endif #endif
if becomeFirstResponder?.wrappedValue == true { if becomeFirstResponder?.wrappedValue == true {
@ -76,7 +76,7 @@ struct EmojiTextField: UIViewRepresentable {
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
let coordinator = Coordinator(controller: controller, text: $text, focusNextView: focusNextView) let coordinator = Coordinator(text: $text, focusNextView: focusNextView)
DispatchQueue.main.async { DispatchQueue.main.async {
inputBox.wrappedValue = coordinator inputBox.wrappedValue = coordinator
} }
@ -84,7 +84,6 @@ struct EmojiTextField: UIViewRepresentable {
} }
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput { class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
let controller: ComposeController
var text: Binding<String> var text: Binding<String>
var focusNextView: Binding<Bool>? var focusNextView: Binding<Bool>?
var maxLength: Int? var maxLength: Int?
@ -94,8 +93,7 @@ struct EmojiTextField: UIViewRepresentable {
weak var textField: UITextField? weak var textField: UITextField?
init(controller: ComposeController, text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) { init(text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
self.controller = controller
self.text = text self.text = text
self.focusNextView = focusNextView self.focusNextView = focusNextView
self.maxLength = maxLength self.maxLength = maxLength
@ -118,16 +116,10 @@ struct EmojiTextField: UIViewRepresentable {
} }
func textFieldDidBeginEditing(_ textField: UITextField) { func textFieldDidBeginEditing(_ textField: UITextField) {
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) {
DispatchQueue.main.async {
self.controller.currentInput = nil
}
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
} }

View File

@ -8,13 +8,17 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import TuskerComponents import TuskerComponents
import TuskerPreferences
struct ReplyStatusView: View { struct ReplyStatusView: View {
let status: any StatusProtocol let status: any StatusProtocol
let rowTopInset: CGFloat let rowTopInset: CGFloat
let globalFrameOutsideList: CGRect let globalFrameOutsideList: CGRect
@EnvironmentObject private var controller: ComposeController @PreferenceObserving(\.$avatarStyle) private var avatarStyle
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
@Environment(\.composeUIConfig.replyContentView) private var replyContentView
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
@State private var displayNameHeight: CGFloat? @State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat? @State private var contentHeight: CGFloat?
@ -27,7 +31,7 @@ struct ReplyStatusView: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack { HStack {
controller.displayNameLabel(status.account, .body, 17) displayNameLabel(status.account, .body, 17)
.lineLimit(1) .lineLimit(1)
.layoutPriority(1) .layoutPriority(1)
@ -46,7 +50,7 @@ struct ReplyStatusView: View {
} }
}) })
controller.replyContentView(status) { newHeight in replyContentView(status) { newHeight in
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update // otherwise, with long in-reply-to statuses, the main content text view position seems not to update
// and it ends up partially behind the header // and it ends up partially behind the header
DispatchQueue.main.async { DispatchQueue.main.async {
@ -80,8 +84,8 @@ struct ReplyStatusView: View {
AvatarImageView( AvatarImageView(
url: status.account.avatar, url: status.account.avatar,
size: 50, size: 50,
style: controller.config.avatarStyle, style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: controller.fetchAvatar fetchAvatar: fetchAvatar
) )
} }
.frame(width: 50, height: 50) .frame(width: 50, height: 50)
@ -90,7 +94,7 @@ struct ReplyStatusView: View {
} }
private struct DisplayNameHeightPrefKey: PreferenceKey { private struct DisplayNameHeightPrefKey: SwiftUI.PreferenceKey {
static var defaultValue: CGFloat = 0 static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue() value = nextValue()

View File

@ -14,6 +14,8 @@ class DuckedPlaceholderViewController: UIViewController {
var topConstraint: NSLayoutConstraint! var topConstraint: NSLayoutConstraint!
private var titleObservation: NSKeyValueObservation?
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) { init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
self.owner = owner self.owner = owner
@ -21,8 +23,12 @@ class DuckedPlaceholderViewController: UIViewController {
let item = UINavigationItem() let item = UINavigationItem()
item.title = duckableViewController.navigationItem.title item.title = duckableViewController.navigationItem.title
item.titleView = duckableViewController.navigationItem.titleView assert(duckableViewController.navigationItem.titleView == nil)
navBar.setItems([item], animated: false) navBar.setItems([item], animated: false)
titleObservation = duckableViewController.navigationItem.observe(\.title, changeHandler: { _, _ in
item.title = duckableViewController.navigationItem.title
})
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {

View File

@ -118,16 +118,17 @@ public struct Client: Sendable {
} }
@discardableResult @discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) { public func run<Result: Sendable>(_ request: Request<Result>) async throws(Error) -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in let response = await withCheckedContinuation { continuation in
run(request) { response in run(request) { response in
continuation.resume(returning: response)
}
}
switch response { switch response {
case .failure(let error): case .failure(let error):
continuation.resume(throwing: error) throw error
case .success(let result, let pagination): case .success(let result, let pagination):
continuation.resume(returning: (result, pagination)) return (result, pagination)
}
}
} }
} }

View File

@ -45,17 +45,8 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
// MARK: ComposeMastodonContext // MARK: ComposeMastodonContext
func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) { func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation({ continuation in return try await client.run(request)
client.run(request) { response in
switch response {
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
case .failure(let error):
continuation.resume(throwing: error)
}
}
})
} }
@MainActor @MainActor

View File

@ -152,6 +152,7 @@ final class MastodonController: ObservableObject, Sendable {
return client.run(request, completion: completion) return client.run(request, completion: completion)
} }
// TODO: remove this in favor of just using the typed throws run(_:) everywhere
func runResponse<Result>(_ request: Request<Result>) async -> Response<Result> { func runResponse<Result>(_ request: Request<Result>) async -> Response<Result> {
let response = await withCheckedContinuation({ continuation in let response = await withCheckedContinuation({ continuation in
client.run(request) { response in client.run(request) { response in
@ -161,15 +162,8 @@ final class MastodonController: ObservableObject, Sendable {
return response return response
} }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) { func run<Result: Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?) {
let response = await runResponse(request) return try await client.run(request)
try Task.checkCancellation()
switch response {
case .failure(let error):
throw error
case .success(let result, let pagination):
return (result, pagination)
}
} }
/// - Returns: A tuple of client ID and client secret. /// - Returns: A tuple of client ID and client secret.

View File

@ -68,8 +68,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
window!.rootViewController = composeVC window!.rootViewController = composeVC
window!.makeKeyAndVisible() window!.makeKeyAndVisible()
updateTitle(draft: composeVC.controller.draft) updateTitle(draft: composeVC.state.draft)
composeVC.controller.$draft composeVC.state.$draft
.sink { [unowned self] in self.updateTitle(draft: $0) } .sink { [unowned self] in self.updateTitle(draft: $0) }
.store(in: &cancellables) .store(in: &cancellables)
@ -82,9 +82,9 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
if let window = window, if let window = window,
let nav = window.rootViewController as? UINavigationController, let nav = window.rootViewController as? UINavigationController,
let compose = nav.topViewController as? ComposeHostingController, let composeVC = nav.topViewController as? ComposeHostingController,
!compose.controller.didPostSuccessfully { !composeVC.state.didPostSuccessfully {
scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) scene.userActivity = UserActivityManager.editDraftActivity(id: composeVC.state.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
} }
} }

View File

@ -27,7 +27,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
weak var delegate: ComposeHostingControllerDelegate? weak var delegate: ComposeHostingControllerDelegate?
let controller: ComposeController
let mastodonController: MastodonController let mastodonController: MastodonController
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -35,28 +34,27 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)? private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
private var drawingCompletion: ((PKDrawing) -> Void)? private var drawingCompletion: ((PKDrawing) -> Void)?
@ObservableObjectBox private var config: ComposeUIConfig
@ObservableObjectBox private var currentAccount: AccountMO?
// Internal visibility so it can be accessed from ComposeSceneDelegate for the window title
let state: ComposeViewState
init(draft: Draft?, mastodonController: MastodonController) { init(draft: Draft?, mastodonController: MastodonController) {
let draft = draft ?? mastodonController.createDraft() let draft = draft ?? mastodonController.createDraft()
self.controller = ComposeController(
draft: draft,
config: ComposeUIConfig(),
mastodonController: mastodonController,
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
fetchAttachment: { @MainActor in await ImageCache.attachments.get($0).1 },
fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
displayNameLabel: { AnyView(AccountDisplayNameView(account: $0, textStyle: $1, emojiSize: $2)) },
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
)
if let account = mastodonController.account {
controller.currentAccount = account
}
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.config = ComposeUIConfig()
self.currentAccount = mastodonController.account
let state = ComposeViewState(draft: draft)
self.state = state
super.init(rootView: View(mastodonController: mastodonController, controller: controller)) let rootView = View(
state: state,
mastodonController: mastodonController,
config: _config,
currentAccount: _currentAccount
)
super.init(rootView: rootView)
self.updateConfig() self.updateConfig()
@ -65,11 +63,11 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil)
// set an initial title immediately, in case we're starting ducked // set an initial title immediately, in case we're starting ducked
self.navigationItem.title = self.controller.navigationTitle self.navigationItem.title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
mastodonController.$account mastodonController.$account
.sink { [unowned self] in .sink { [unowned self] in
self.controller.currentAccount = $0 self.currentAccount = $0
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
@ -98,21 +96,26 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
config.dismiss = { [weak self] in self?.dismiss(mode: $0) } config.dismiss = { [weak self] in self?.dismiss(mode: $0) }
config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) } config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) }
config.presentDrawing = { [unowned self] in self.presentDrawing($0, completion: $1) } config.presentDrawing = { [unowned self] in self.presentDrawing($0, completion: $1) }
config.userActivityForDraft = { [unowned self] in config.userActivityForDraft = { [mastodonController] in
let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: self.mastodonController.accountInfo!.id) let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
return NSItemProvider(object: activity) return NSItemProvider(object: activity)
} }
config.fetchAvatar = { @MainActor in await ImageCache.avatars.get($0).1 }
config.displayNameLabel = { AnyView(AccountDisplayNameView(account: $0, textStyle: $1, emojiSize: $2)) }
config.replyContentView = { [mastodonController] in AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }
controller.config = config self.config = config
} }
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
return controller.canPaste(itemProviders: itemProviders) // return controller.canPaste(itemProviders: itemProviders)
return false
// TODO: pasting
} }
override func paste(itemProviders: [NSItemProvider]) { override func paste(itemProviders: [NSItemProvider]) {
controller.paste(itemProviders: itemProviders) // controller.paste(itemProviders: itemProviders)
} }
private func dismiss(mode: DismissMode) { private func dismiss(mode: DismissMode) {
@ -149,26 +152,54 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
struct View: SwiftUI.View { struct View: SwiftUI.View {
let mastodonController: MastodonController let mastodonController: MastodonController
let controller: ComposeController @ObservedObject @ObservableObjectBox private var config: ComposeUIConfig
@ObservedObject @ObservableObjectBox private var currentAccount: AccountMO?
let state: ComposeViewState
fileprivate init(
state: ComposeViewState,
mastodonController: MastodonController,
config: ObservableObjectBox<ComposeUIConfig>,
currentAccount: ObservableObjectBox<AccountMO?>
) {
self.state = state
self.mastodonController = mastodonController
self._config = ObservedObject(wrappedValue: config)
self._currentAccount = ObservedObject(wrappedValue: currentAccount)
}
var body: some SwiftUI.View { var body: some SwiftUI.View {
ControllerView(controller: { controller }) ComposeView(
state: state,
mastodonController: mastodonController,
currentAccount: currentAccount,
config: config
)
.task { .task {
if controller.currentAccount == nil, if currentAccount == nil,
let account = try? await mastodonController.getOwnAccount() { let account = try? await mastodonController.getOwnAccount() {
controller.currentAccount = account currentAccount = account
} }
} }
} }
} }
} }
@MainActor
@propertyWrapper
private final class ObservableObjectBox<T>: ObservableObject {
@Published var wrappedValue: T
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
}
#if canImport(Duckable) #if canImport(Duckable)
extension ComposeHostingController: DuckableViewController { extension ComposeHostingController: DuckableViewController {
func duckableViewControllerShouldDuck() -> DuckAttemptAction { func duckableViewControllerShouldDuck() -> DuckAttemptAction {
if controller.isPosting { if state.isPosting {
return .block return .block
} else if controller.draft.hasContent { } else if state.draft.hasContent {
return .duck return .duck
} else { } else {
return .dismiss return .dismiss
@ -176,15 +207,12 @@ extension ComposeHostingController: DuckableViewController {
} }
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
controller.deleteDraftOnDisappear = false navigationItem.title = ComposeView.navigationTitle(for: state.draft, mastodonController: mastodonController)
config.showToolbar = false
withAnimation(.linear(duration: duration).delay(delay)) {
controller.showToolbar = false
}
} }
func duckableViewControllerDidFinishAnimatingDuck() { func duckableViewControllerDidFinishAnimatingDuck() {
controller.showToolbar = true config.showToolbar = true
} }
} }
#endif #endif

View File

@ -1,14 +0,0 @@
//
// ComposeHostingController.swift
// Tusker
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import UIKit
class ComposeHostingController: UIHostingController<ComposeView> {
}

View File

@ -178,8 +178,9 @@ extension BaseMainTabBarViewController: StateRestorableViewController {
var activity: NSUserActivity? var activity: NSUserActivity?
if let presentedNav = presentedViewController as? UINavigationController, if let presentedNav = presentedViewController as? UINavigationController,
let compose = presentedNav.viewControllers.first as? ComposeHostingController { let compose = presentedNav.viewControllers.first as? ComposeHostingController {
let draft = compose.controller.draft // TODO: this
activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID) // let draft = compose.controller.draft
// activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID)
} else if let vc = (selectedViewController as? any NavigationControllerProtocol)?.topViewController as? StateRestorableViewController { } else if let vc = (selectedViewController as? any NavigationControllerProtocol)?.topViewController as? StateRestorableViewController {
activity = vc.stateRestorationActivity() activity = vc.stateRestorationActivity()
} }

View File

@ -16,10 +16,11 @@ import ComposeUI
extension DuckableContainerViewController: AccountSwitchableViewController { extension DuckableContainerViewController: AccountSwitchableViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity() var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
if let compose = duckedViewController as? ComposeHostingController, // TODO: this
compose.controller.draft.hasContent { // if let compose = duckedViewController as? ComposeHostingController,
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft) // compose.controller.draft.hasContent {
} // activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft)
// }
return activity return activity
} }