Compare commits
No commits in common. "da4787946da1841a5d2011e9fa574b17a78f2bb0" and "ec3678d90ddfd4419ad0abfce1685cc9c6d0a3ea" have entirely different histories.
da4787946d
...
ec3678d90d
@ -12,20 +12,20 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PostService: ObservableObject {
|
final class PostService: ObservableObject {
|
||||||
private let mastodonController: any ComposeMastodonContext
|
private let mastodonController: 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: any ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
|
init(mastodonController: 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(Error) {
|
func post() async throws {
|
||||||
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 {
|
} catch let error as Client.Error {
|
||||||
throw Error.posting(error)
|
throw Error.posting(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadAttachments() async throws(Error) -> [String] {
|
private func uploadAttachments() async throws -> [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 {
|
} catch let error as DraftAttachment.ExportError {
|
||||||
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,21 +141,20 @@ final class PostService: ObservableObject {
|
|||||||
return attachments
|
return attachments
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getData(for attachment: DraftAttachment) async throws(DraftAttachment.ExportError) -> (Data, UTType) {
|
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
|
||||||
let result = await withCheckedContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
attachment.getData(features: mastodonController.instanceFeatures) { result in
|
attachment.getData(features: mastodonController.instanceFeatures) { result in
|
||||||
continuation.resume(returning: result)
|
switch result {
|
||||||
|
case let .success(res):
|
||||||
|
continuation.resume(returning: res)
|
||||||
|
case let .failure(error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch result {
|
|
||||||
case .success(let result):
|
|
||||||
return result
|
|
||||||
case .failure(let error):
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws(Error) -> Attachment {
|
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> 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)
|
||||||
}
|
}
|
||||||
@ -167,7 +166,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 {
|
} catch let error as Client.Error {
|
||||||
throw Error.attachmentUpload(index: index, cause: error)
|
throw Error.attachmentUpload(index: index, cause: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(Client.Error) -> (Result, Pagination?)
|
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
|
||||||
|
|
||||||
func getCustomEmojis() async -> [Emoji]
|
func getCustomEmojis() async -> [Emoji]
|
||||||
|
|
||||||
|
@ -11,12 +11,10 @@ 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)
|
||||||
@ -35,9 +33,6 @@ 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() {
|
||||||
}
|
}
|
||||||
|
@ -132,11 +132,17 @@ 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)
|
.environment(\.composeUIConfig, config)
|
||||||
.environment(\.composeUIConfig, config)
|
} else {
|
||||||
|
ComposeView(poster: poster)
|
||||||
|
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||||
|
.environmentObject(draft)
|
||||||
|
.environmentObject(mastodonController.instanceFeatures)
|
||||||
|
.environment(\.composeUIConfig, config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -70,15 +70,7 @@ public class Draft: NSManagedObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Draft {
|
extension Draft {
|
||||||
var hasText: Bool {
|
public var hasContent: 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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 final class DraftsPersistentContainer: NSPersistentContainer {
|
public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
public static let shared = DraftsPersistentContainer()
|
public static let shared = DraftsPersistentContainer()
|
||||||
|
|
||||||
|
@ -64,12 +64,7 @@ struct AttachmentsGalleryDataSource: GalleryDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
|
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
|
||||||
if let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? HostingCollectionViewCell {
|
collectionView.cellForItem(at: IndexPath(item: index, section: 0))
|
||||||
// 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?)? {
|
||||||
|
@ -363,7 +363,7 @@ private final class IntrinsicContentSizeCollectionView: UICollectionView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
final class HostingCollectionViewCell: UICollectionViewCell {
|
private 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 @@ final class HostingCollectionViewCell: UICollectionViewCell {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
@available(iOS, obsoleted: 16.0)
|
@available(iOS, obsoleted: 16.0)
|
||||||
final class HostingCollectionViewCell: UICollectionViewCell {
|
private class HostingCollectionViewCell: UICollectionViewCell {
|
||||||
weak var containingViewController: UIViewController?
|
weak var containingViewController: UIViewController?
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ -11,67 +11,49 @@ 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) {
|
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
|
||||||
ToolbarCancelButton(draft: draft, isPosting: isPosting, cancel: cancel)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
#if targetEnvironment(macCatalyst)
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) { DraftsButton(isShowingDrafts: $isShowingDrafts) }
|
||||||
DraftsButton(isShowingDrafts: $isShowingDrafts)
|
ToolbarItem(placement: .confirmationAction) { PostButton(draft: draft, isPosting: isPosting) }
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
PostButton(draft: draft, isPosting: isPosting)
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) { PostOrDraftsButton(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: isPosting) }
|
||||||
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: self.showConfirmationOrCancel) {
|
Button(role: .cancel, action: controller.cancel) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
}
|
}
|
||||||
.disabled(isPosting)
|
.disabled(controller.isPosting)
|
||||||
.confirmationDialog("Are you sure?", isPresented: $isShowingSaveDraftSheet) {
|
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
||||||
// edit drafts can't be saved
|
// edit drafts can't be saved
|
||||||
if draft.editedStatusID == nil {
|
if draft.editedStatusID == nil {
|
||||||
Button(action: { cancel(false) }) {
|
Button(action: { controller.cancel(deleteDraft: false) }) {
|
||||||
Text("Save Draft")
|
Text("Save Draft")
|
||||||
}
|
}
|
||||||
Button(role: .destructive, action: { cancel(true) }) {
|
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||||
Text("Delete Draft")
|
Text("Delete Draft")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(role: .destructive, action: { cancel(true) }) {
|
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||||
Text("Cancel Edit")
|
Text("Cancel Edit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showConfirmationOrCancel() {
|
|
||||||
if draft.hasContent {
|
|
||||||
isShowingSaveDraftSheet = true
|
|
||||||
} else {
|
|
||||||
cancel(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
@ -79,12 +61,11 @@ 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, postStatus: postStatus)
|
PostButton(draft: draft, isPosting: isPosting)
|
||||||
} else {
|
} else {
|
||||||
DraftsButton(isShowingDrafts: $isShowingDrafts)
|
DraftsButton(isShowingDrafts: $isShowingDrafts)
|
||||||
}
|
}
|
||||||
@ -99,17 +80,12 @@ 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 {
|
Button(action: controller.postStatus) {
|
||||||
Task {
|
|
||||||
await postStatus()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
@ -149,7 +125,7 @@ private struct PostButton: View {
|
|||||||
|
|
||||||
private var draftValid: Bool {
|
private var draftValid: Bool {
|
||||||
draft.editedStatusID != nil ||
|
draft.editedStatusID != nil ||
|
||||||
((draft.hasText || draft.attachments.count > 0)
|
((draft.hasContent || draft.attachments.count > 0)
|
||||||
&& hasCharactersRemaining
|
&& hasCharactersRemaining
|
||||||
&& attachmentsValid
|
&& attachmentsValid
|
||||||
&& pollValid)
|
&& pollValid)
|
||||||
|
@ -7,79 +7,21 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import CoreData
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
// State owned by the compose UI but that needs to be accessible from outside.
|
struct ComposeView: View {
|
||||||
public final class ComposeViewState: ObservableObject {
|
|
||||||
@Published var poster: PostService?
|
|
||||||
@Published public internal(set) var draft: Draft
|
|
||||||
@Published public internal(set) var didPostSuccessfully = false
|
|
||||||
|
|
||||||
public var isPosting: Bool {
|
|
||||||
poster != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
@ObservedObject var draft: Draft
|
||||||
let mastodonController: any ComposeMastodonContext
|
let mastodonController: any ComposeMastodonContext
|
||||||
@ObservedObject var state: ComposeViewState
|
// @State private var poster: PostService? = nil
|
||||||
let setDraft: (Draft) -> Void
|
|
||||||
@State private var postError: PostService.Error?
|
|
||||||
@FocusState private var focusedField: FocusableField?
|
@FocusState private var focusedField: FocusableField?
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
@State private var isShowingDrafts = false
|
@State private var isShowingDrafts = false
|
||||||
@State private var isDismissing = false
|
|
||||||
@State private var userConfirmedDelete = false
|
// TODO: replace this with an @State owned by this view
|
||||||
@Environment(\.composeUIConfig) private var config
|
var poster: PostService? {
|
||||||
|
controller.poster
|
||||||
public var body: some View {
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
navigation
|
navigation
|
||||||
.environmentObject(mastodonController.instanceFeatures)
|
.environmentObject(mastodonController.instanceFeatures)
|
||||||
.sheet(isPresented: $isShowingDrafts) {
|
.sheet(isPresented: $isShowingDrafts) {
|
||||||
@ -87,13 +29,9 @@ private struct ComposeViewBody: View {
|
|||||||
currentDraft: draft,
|
currentDraft: draft,
|
||||||
isShowingDrafts: $isShowingDrafts,
|
isShowingDrafts: $isShowingDrafts,
|
||||||
accountInfo: mastodonController.accountInfo!,
|
accountInfo: mastodonController.accountInfo!,
|
||||||
selectDraft: {
|
selectDraft: self.selectDraft
|
||||||
self.setDraft($0)
|
|
||||||
self.isShowingDrafts = false
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onDisappear(perform: self.deleteOrSaveDraft)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -127,22 +65,19 @@ private struct ComposeViewBody: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if let poster = state.poster {
|
if let 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
|
||||||
@ -151,7 +86,7 @@ private struct ComposeViewBody: View {
|
|||||||
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ComposeNavigationBarActions(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: state.isPosting, cancel: self.cancel(deleteDraft:), postStatus: self.postStatus)
|
ComposeNavigationBarActions(draft: draft, controller: controller, isShowingDrafts: $isShowingDrafts, isPosting: poster != nil)
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
ToolbarItem(placement: .bottomOrnament) {
|
ToolbarItem(placement: .bottomOrnament) {
|
||||||
toolbarView
|
toolbarView
|
||||||
@ -178,52 +113,17 @@ private struct ComposeViewBody: View {
|
|||||||
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
|
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteOrSaveDraft() {
|
private func selectDraft(_ draft: Draft) {
|
||||||
if isDismissing,
|
controller.selectDraft(draft)
|
||||||
!draft.hasContent || state.didPostSuccessfully || userConfirmedDelete {
|
isShowingDrafts = false
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeView {
|
private struct NavigationTitleModifier: ViewModifier {
|
||||||
public static func navigationTitle(for draft: Draft, mastodonController: any ComposeMastodonContext) -> String {
|
let draft: Draft
|
||||||
|
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)"
|
||||||
@ -233,16 +133,19 @@ extension ComposeView {
|
|||||||
return "New Post"
|
return "New Post"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private struct NavigationTitleModifier: ViewModifier {
|
|
||||||
let draft: Draft
|
|
||||||
let mastodonController: any ComposeMastodonContext
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
|
|
||||||
content
|
content
|
||||||
.navigationTitle(title)
|
.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,7 +8,6 @@
|
|||||||
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
|
||||||
@ -56,15 +55,15 @@ struct DraftEditor: View {
|
|||||||
|
|
||||||
private struct AvatarView: View {
|
private struct AvatarView: View {
|
||||||
let account: (any AccountProtocol)?
|
let account: (any AccountProtocol)?
|
||||||
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
|
@Environment(\.composeUIConfig.avatarStyle) private var avatarStyle
|
||||||
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
AvatarImageView(
|
AvatarImageView(
|
||||||
url: account?.avatar,
|
url: account?.avatar,
|
||||||
size: 50,
|
size: 50,
|
||||||
style: avatarStyle == .circle ? .circle : .roundRect,
|
style: avatarStyle,
|
||||||
fetchAvatar: fetchAvatar
|
fetchAvatar: controller.fetchAvatar
|
||||||
)
|
)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
@ -72,11 +71,11 @@ private struct AvatarView: View {
|
|||||||
|
|
||||||
private struct AccountNameView: View {
|
private struct AccountNameView: View {
|
||||||
let account: any AccountProtocol
|
let account: any AccountProtocol
|
||||||
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
displayNameLabel(account, .body, 16)
|
controller.displayNameLabel(account, .body, 16)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text(verbatim: "@\(account.acct)")
|
Text(verbatim: "@\(account.acct)")
|
||||||
|
@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
struct EmojiTextField: UIViewRepresentable {
|
struct EmojiTextField: UIViewRepresentable {
|
||||||
typealias UIViewType = UITextField
|
typealias UIViewType = UITextField
|
||||||
|
|
||||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
@EnvironmentObject private var controller: ComposeController
|
||||||
@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(fillColor) : .secondarySystemBackground
|
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.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(text: $text, focusNextView: focusNextView)
|
let coordinator = Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
inputBox.wrappedValue = coordinator
|
inputBox.wrappedValue = coordinator
|
||||||
}
|
}
|
||||||
@ -84,6 +84,7 @@ 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?
|
||||||
@ -93,7 +94,8 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
|
|
||||||
weak var textField: UITextField?
|
weak var textField: UITextField?
|
||||||
|
|
||||||
init(text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
|
init(controller: ComposeController, 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
|
||||||
@ -116,10 +118,16 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,17 +8,13 @@
|
|||||||
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
|
||||||
|
|
||||||
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
|
@EnvironmentObject private var controller: ComposeController
|
||||||
@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?
|
||||||
|
|
||||||
@ -31,7 +27,7 @@ struct ReplyStatusView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
displayNameLabel(status.account, .body, 17)
|
controller.displayNameLabel(status.account, .body, 17)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
@ -50,7 +46,7 @@ struct ReplyStatusView: View {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
replyContentView(status) { newHeight in
|
controller.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 {
|
||||||
@ -84,8 +80,8 @@ struct ReplyStatusView: View {
|
|||||||
AvatarImageView(
|
AvatarImageView(
|
||||||
url: status.account.avatar,
|
url: status.account.avatar,
|
||||||
size: 50,
|
size: 50,
|
||||||
style: avatarStyle == .circle ? .circle : .roundRect,
|
style: controller.config.avatarStyle,
|
||||||
fetchAvatar: fetchAvatar
|
fetchAvatar: controller.fetchAvatar
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
@ -94,7 +90,7 @@ struct ReplyStatusView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DisplayNameHeightPrefKey: SwiftUI.PreferenceKey {
|
private struct DisplayNameHeightPrefKey: 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()
|
||||||
|
@ -14,8 +14,6 @@ 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
|
||||||
|
|
||||||
@ -23,12 +21,8 @@ class DuckedPlaceholderViewController: UIViewController {
|
|||||||
|
|
||||||
let item = UINavigationItem()
|
let item = UINavigationItem()
|
||||||
item.title = duckableViewController.navigationItem.title
|
item.title = duckableViewController.navigationItem.title
|
||||||
assert(duckableViewController.navigationItem.titleView == nil)
|
item.titleView = duckableViewController.navigationItem.titleView
|
||||||
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) {
|
||||||
|
@ -118,18 +118,17 @@ public struct Client: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func run<Result: Sendable>(_ request: Request<Result>) async throws(Error) -> (Result, Pagination?) {
|
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||||
let response = await withCheckedContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
run(request) { response in
|
run(request) { response in
|
||||||
continuation.resume(returning: response)
|
switch response {
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
case .success(let result, let pagination):
|
||||||
|
continuation.resume(returning: (result, pagination))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch response {
|
|
||||||
case .failure(let error):
|
|
||||||
throw error
|
|
||||||
case .success(let result, let pagination):
|
|
||||||
return (result, pagination)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||||
|
@ -45,8 +45,17 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
|
|||||||
|
|
||||||
// MARK: ComposeMastodonContext
|
// MARK: ComposeMastodonContext
|
||||||
|
|
||||||
func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?) {
|
func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||||
return try await client.run(request)
|
return try await withCheckedThrowingContinuation({ continuation in
|
||||||
|
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
|
||||||
|
@ -152,7 +152,6 @@ 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
|
||||||
@ -162,8 +161,15 @@ final class MastodonController: ObservableObject, Sendable {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func run<Result: Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?) {
|
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||||
return try await client.run(request)
|
let response = await runResponse(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.
|
||||||
|
@ -68,8 +68,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
|||||||
window!.rootViewController = composeVC
|
window!.rootViewController = composeVC
|
||||||
window!.makeKeyAndVisible()
|
window!.makeKeyAndVisible()
|
||||||
|
|
||||||
updateTitle(draft: composeVC.state.draft)
|
updateTitle(draft: composeVC.controller.draft)
|
||||||
composeVC.state.$draft
|
composeVC.controller.$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 composeVC = nav.topViewController as? ComposeHostingController,
|
let compose = nav.topViewController as? ComposeHostingController,
|
||||||
!composeVC.state.didPostSuccessfully {
|
!compose.controller.didPostSuccessfully {
|
||||||
scene.userActivity = UserActivityManager.editDraftActivity(id: composeVC.state.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
|
scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ 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>()
|
||||||
@ -34,27 +35,28 @@ 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.mastodonController = mastodonController
|
self.controller = ComposeController(
|
||||||
self.config = ComposeUIConfig()
|
draft: draft,
|
||||||
self.currentAccount = mastodonController.account
|
config: ComposeUIConfig(),
|
||||||
let state = ComposeViewState(draft: draft)
|
|
||||||
self.state = state
|
|
||||||
|
|
||||||
let rootView = View(
|
|
||||||
state: state,
|
|
||||||
mastodonController: mastodonController,
|
mastodonController: mastodonController,
|
||||||
config: _config,
|
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
|
||||||
currentAccount: _currentAccount
|
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)) }
|
||||||
)
|
)
|
||||||
super.init(rootView: rootView)
|
|
||||||
|
if let account = mastodonController.account {
|
||||||
|
controller.currentAccount = account
|
||||||
|
}
|
||||||
|
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
|
super.init(rootView: View(mastodonController: mastodonController, controller: controller))
|
||||||
|
|
||||||
self.updateConfig()
|
self.updateConfig()
|
||||||
|
|
||||||
@ -63,11 +65,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 = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
|
self.navigationItem.title = self.controller.navigationTitle
|
||||||
|
|
||||||
mastodonController.$account
|
mastodonController.$account
|
||||||
.sink { [unowned self] in
|
.sink { [unowned self] in
|
||||||
self.currentAccount = $0
|
self.controller.currentAccount = $0
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
@ -96,26 +98,21 @@ 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 = { [mastodonController] in
|
config.userActivityForDraft = { [unowned self] in
|
||||||
let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: mastodonController.accountInfo!.id)
|
let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: self.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)) }
|
|
||||||
|
|
||||||
self.config = config
|
controller.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) {
|
||||||
@ -152,54 +149,26 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||||||
|
|
||||||
struct View: SwiftUI.View {
|
struct View: SwiftUI.View {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
@ObservedObject @ObservableObjectBox private var config: ComposeUIConfig
|
let controller: ComposeController
|
||||||
@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 {
|
||||||
ComposeView(
|
ControllerView(controller: { controller })
|
||||||
state: state,
|
.task {
|
||||||
mastodonController: mastodonController,
|
if controller.currentAccount == nil,
|
||||||
currentAccount: currentAccount,
|
let account = try? await mastodonController.getOwnAccount() {
|
||||||
config: config
|
controller.currentAccount = account
|
||||||
)
|
}
|
||||||
.task {
|
|
||||||
if currentAccount == nil,
|
|
||||||
let account = try? await mastodonController.getOwnAccount() {
|
|
||||||
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 state.isPosting {
|
if controller.isPosting {
|
||||||
return .block
|
return .block
|
||||||
} else if state.draft.hasContent {
|
} else if controller.draft.hasContent {
|
||||||
return .duck
|
return .duck
|
||||||
} else {
|
} else {
|
||||||
return .dismiss
|
return .dismiss
|
||||||
@ -207,12 +176,15 @@ extension ComposeHostingController: DuckableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
||||||
navigationItem.title = ComposeView.navigationTitle(for: state.draft, mastodonController: mastodonController)
|
controller.deleteDraftOnDisappear = false
|
||||||
config.showToolbar = false
|
|
||||||
|
withAnimation(.linear(duration: duration).delay(delay)) {
|
||||||
|
controller.showToolbar = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func duckableViewControllerDidFinishAnimatingDuck() {
|
func duckableViewControllerDidFinishAnimatingDuck() {
|
||||||
config.showToolbar = true
|
controller.showToolbar = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
14
Tusker/Screens/Compose/ComposeHostingViewController.swift
Normal file
14
Tusker/Screens/Compose/ComposeHostingViewController.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// ComposeHostingController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/18/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ComposeHostingController: UIHostingController<ComposeView> {
|
||||||
|
|
||||||
|
}
|
@ -178,9 +178,8 @@ 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 {
|
||||||
// TODO: this
|
let draft = compose.controller.draft
|
||||||
// let draft = compose.controller.draft
|
activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID)
|
||||||
// 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()
|
||||||
}
|
}
|
||||||
|
@ -16,11 +16,10 @@ 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()
|
||||||
// TODO: this
|
if let compose = duckedViewController as? ComposeHostingController,
|
||||||
// if let compose = duckedViewController as? ComposeHostingController,
|
compose.controller.draft.hasContent {
|
||||||
// compose.controller.draft.hasContent {
|
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft)
|
||||||
// activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft)
|
}
|
||||||
// }
|
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user