diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index dd8eb06f..a57b95ad 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -135,6 +135,7 @@ public final class ComposeController: ViewController { if Preferences.shared.hasFeatureFlag(.composeRewrite) { ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController) .environment(\.currentAccount, currentAccount) + .environment(\.composeUIConfig, config) } else { ComposeView(poster: poster) .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentRowView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentRowView.swift new file mode 100644 index 00000000..9539418f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentRowView.swift @@ -0,0 +1,20 @@ +// +// AttachmentRowView.swift +// ComposeUI +// +// Created by Shadowfacts on 8/18/24. +// + +import SwiftUI + +struct AttachmentRowView: View { + @ObservedObject var attachment: DraftAttachment + + var body: some View { + Text(attachment.id.uuidString) + } +} + +//#Preview { +// AttachmentRowView() +//} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift new file mode 100644 index 00000000..f1002f9b --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift @@ -0,0 +1,407 @@ +// +// AttachmentsListView.swift +// ComposeUI +// +// Created by Shadowfacts on 8/16/24. +// + +import SwiftUI +import InstanceFeatures +import CoreData +import PhotosUI + +struct AttachmentsListView: View { + @ObservedObject var draft: Draft + @ObservedObject var instanceFeatures: InstanceFeatures + @State private var attachmentHeights = [NSManagedObjectID: CGFloat]() + @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker + + private var canAddAttachment: Bool { + if instanceFeatures.mastodonAttachmentRestrictions { + return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil + } else { + return true + } + } + + private var totalHeight: CGFloat { + let buttonsHeight = 3 * (40 + AttachmentsListPaddingModifier.cellPadding) + let rowHeights = draft.attachments.compactMap { + attachmentHeights[($0 as! NSManagedObject).objectID] + }.reduce(0) { partialResult, height in + partialResult + height + AttachmentsListPaddingModifier.cellPadding + } + return buttonsHeight + rowHeights + } + + var body: some View { + if #available(iOS 16.0, *) { + WrappedCollectionView(attachments: draft.draftAttachments, hasPoll: draft.poll != nil, callbacks: Callbacks(draft: draft, presentAssetPicker: presentAssetPicker)) + // Impose a minimum height, because otherwise it deafults to zero which prevents the collection + // view from laying out, and leaving the intrinsic content size at zero too. + .frame(minHeight: 50) + .padding(.horizontal, -8) + } else { + List { + content + } + .listStyle(.plain) + .frame(height: totalHeight) + .scrollDisabledIfAvailable(true) + } + } + + @ViewBuilder + private var content: some View { + ForEach(draft.draftAttachments) { attachment in + AttachmentRowView(attachment: attachment) + .modifier(AttachmentRowHeightModifier(height: $attachmentHeights[attachment.objectID])) + } + .onMove(perform: self.moveAttachments) + .onDelete(perform: self.removeAttachments) + + AddPhotoButton(canAddAttachment: canAddAttachment, draft: draft, insertAttachments: self.insertAttachments) + + AddDrawingButton(canAddAttachment: canAddAttachment) + + TogglePollButton(poll: draft.poll) + } + + private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { + for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { + provider.loadObject(ofClass: DraftAttachment.self) { object, error in + guard let attachment = object as? DraftAttachment else { return } + DispatchQueue.main.async { + guard self.canAddAttachment else { + return + } + DraftsPersistentContainer.shared.viewContext.insert(attachment) + attachment.draft = self.draft + self.draft.attachments.add(attachment) + } + } + } + } + + private func moveAttachments(from source: IndexSet, to destination: Int) { + // just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet + // results in the order switching back to the previous order and then to the correct one + // on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem + var array = draft.draftAttachments + array.move(fromOffsets: source, toOffset: destination) + draft.attachments = NSMutableOrderedSet(array: array) + } + + private func removeAttachments(at indices: IndexSet) { + var array = draft.draftAttachments + array.remove(atOffsets: indices) + draft.attachments = NSMutableOrderedSet(array: array) + } +} + +private struct Callbacks: AttachmentsListCallbacks { + let draft: Draft + let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? + + private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { + for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { + provider.loadObject(ofClass: DraftAttachment.self) { object, error in + guard let attachment = object as? DraftAttachment else { return } + DispatchQueue.main.async { +// guard self.canAddAttachment else { +// return +// } + DraftsPersistentContainer.shared.viewContext.insert(attachment) + attachment.draft = self.draft + self.draft.attachments.add(attachment) + } + } + } + } + + + func addPhoto() { + presentAssetPicker?() { + insertAttachments(at: draft.attachments.count, itemProviders: $0.map(\.itemProvider)) + } + } + + func addDrawing() { + } + + func togglePoll() { + draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil + } +} + +private struct AttachmentRowHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat { 0 } + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +private struct AttachmentRowHeightModifier: ViewModifier { + @Binding var height: CGFloat? + + func body(content: Content) -> some View { + content + .background { + GeometryReader { proxy in + Color.clear + // do the preference dance because onChange(of:inital:_:) is iOS 17+ :/ + .preference(key: AttachmentRowHeightPreferenceKey.self, value: proxy.size.height) + .onPreferenceChange(AttachmentRowHeightPreferenceKey.self) { newValue in + height = newValue + } + } + } + } +} + +private struct AttachmentsListPaddingModifier: ViewModifier { + static let cellPadding: CGFloat = 12 + + func body(content: Content) -> some View { + content + .listRowInsets(EdgeInsets(top: Self.cellPadding / 2, leading: Self.cellPadding / 2, bottom: Self.cellPadding / 2, trailing: Self.cellPadding / 2)) + } +} + +private struct AttachmentsListButton: View { + let action: () -> Void + @ViewBuilder let label: Label + + var body: some View { + Button(action: action) { + label + } + .foregroundStyle(.tint) + .frame(height: 40) + .modifier(AttachmentsListPaddingModifier()) + } +} + +private struct AddPhotoButton: View { + let canAddAttachment: Bool + let draft: Draft + let insertAttachments: (Int, [NSItemProvider]) -> Void + @Environment(\.colorScheme) private var colorScheme + @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker + + var body: some View { + AttachmentsListButton { + presentAssetPicker?() { results in + insertAttachments(draft.attachments.count, results.map(\.itemProvider)) + } + } label: { + Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo") + } + .disabled(!canAddAttachment) + } +} + +private struct AddDrawingButton: View { + let canAddAttachment: Bool + + var body: some View { + AttachmentsListButton { + fatalError("TODO") + } label: { + Label("Draw something", systemImage: "hand.draw") + } + .disabled(!canAddAttachment) + } +} + +private struct TogglePollButton: View { + let poll: Poll? + + var canAddPoll: Bool { + // TODO + true + } + + var body: some View { + AttachmentsListButton { + fatalError("TODO") + } label: { + Label(poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") + } + .disabled(!canAddPoll) + } +} + +@available(iOS 16.0, *) +private struct WrappedCollectionView: UIViewRepresentable { + let attachments: [DraftAttachment] + let hasPoll: Bool + let callbacks: AttachmentsListCallbacks + + func makeUIView(context: Context) -> UICollectionView { + let config = UICollectionLayoutListConfiguration(appearance: .plain) + let layout = UICollectionViewCompositionalLayout.list(using: config) + let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout) + let dataSource = UICollectionViewDiffableDataSource(collectionView: view) { collectionView, indexPath, itemIdentifier in + context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier) + } + view.dataSource = dataSource + context.coordinator.dataSource = dataSource + view.delegate = context.coordinator + view.isScrollEnabled = false + return view + } + + func updateUIView(_ uiView: UICollectionView, context: Context) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.attachments, .buttons]) + snapshot.appendItems(attachments.map { + .attachment($0) + }, toSection: .attachments) + snapshot.appendItems([ + .button(.addPhoto), + .button(.addDrawing), + .button(.togglePoll(adding: !hasPoll)) + ], toSection: .buttons) + context.coordinator.dataSource.apply(snapshot) + context.coordinator.callbacks = callbacks + } + + func makeCoordinator() -> WrappedCollectionViewCoordinator { + WrappedCollectionViewCoordinator(callbacks: callbacks) + } + + enum Section: Hashable { + case attachments, buttons + } + + enum Item: Hashable { + case attachment(DraftAttachment) + case button(Button) + + static func ==(lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.attachment(a), .attachment(b)): + return a.objectID == b.objectID + case let (.button(a), .button(b)): + return a == b + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .attachment(let draftAttachment): + hasher.combine(0) + hasher.combine(draftAttachment.objectID) + case .button(let button): + hasher.combine(1) + hasher.combine(button) + } + } + } + + enum Button: Hashable { + case addPhoto + case addDrawing + case togglePoll(adding: Bool) + } +} + +private final class IntrinsicContentSizeCollectionView: UICollectionView { + private var _intrinsicContentSize = CGSize.zero + + override var intrinsicContentSize: CGSize { + _intrinsicContentSize + } + + override func layoutSubviews() { + super.layoutSubviews() + + if contentSize != _intrinsicContentSize { + _intrinsicContentSize = contentSize + invalidateIntrinsicContentSize() + } + } +} + +protocol AttachmentsListCallbacks { + func addPhoto() + + func addDrawing() + + func togglePoll() +} + +@available(iOS 16.0, *) +private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate { + var callbacks: AttachmentsListCallbacks + + var dataSource: UICollectionViewDiffableDataSource! + + init(callbacks: AttachmentsListCallbacks) { + self.callbacks = callbacks + } + + private let attachmentCell = UICollectionView.CellRegistration { cell, indexPath, item in + cell.contentConfiguration = UIHostingConfiguration { + Text(item.id.uuidString) + } + } + + private let buttonCell = UICollectionView.CellRegistration { cell, indexPath, item in + var config = cell.defaultContentConfiguration() + switch item { + case .addPhoto: + config.image = UIImage(systemName: "photo") + config.text = "Add photo or video" + case .addDrawing: + config.image = UIImage(systemName: "hand.draw") + config.text = "Add drawing" + case .togglePoll(let adding): + config.image = UIImage(systemName: "chart.bar.doc.horizontal") + config.text = adding ? "Add a poll" : "Remove poll" + } + config.textProperties.color = .tintColor + cell.contentConfiguration = config + } + + func makeCell(collectionView: UICollectionView, indexPath: IndexPath, item: WrappedCollectionView.Item) -> UICollectionViewCell { + switch item { + case .attachment(let attachment): + return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment) + case .button(let button): + return collectionView.dequeueConfiguredReusableCell(using: buttonCell, for: indexPath, item: button) + } + } + + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + switch dataSource.itemIdentifier(for: indexPath)! { + case .attachment: + return false + case .button: + return true + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: false) + guard case .button(let button) = dataSource.itemIdentifier(for: indexPath) else { + return + } + switch button { + case .addPhoto: + callbacks.addPhoto() + case .addDrawing: + callbacks.addDrawing() + case .togglePoll: + callbacks.togglePoll() + } + } +} + + +//#Preview { +// AttachmentsListView() +//} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index 488396fa..ee40a87b 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -79,6 +79,8 @@ struct ComposeView: View { ContentWarningTextField(draft: draft, focusedField: $focusedField) NewMainTextView(value: $draft.text, focusedField: $focusedField) + + AttachmentsListView(draft: draft, instanceFeatures: mastodonController.instanceFeatures) } .padding(8) } diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 32996a8b..ef5e276b 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -133,7 +133,6 @@ class ComposeHostingController: UIHostingController