Compare commits
No commits in common. "8006b0add965552fef95e29b9dd97fd73da51c7c" and "57990f83393e1662e38e338411e64e61bd8edaa3" have entirely different histories.
8006b0add9
...
57990f8339
|
@ -21,14 +21,13 @@ let package = Package(
|
||||||
.package(path: "../TuskerComponents"),
|
.package(path: "../TuskerComponents"),
|
||||||
.package(path: "../MatchedGeometryPresentation"),
|
.package(path: "../MatchedGeometryPresentation"),
|
||||||
.package(path: "../TuskerPreferences"),
|
.package(path: "../TuskerPreferences"),
|
||||||
.package(path: "../UserAccounts"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences", "UserAccounts"],
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences"],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.swiftLanguageMode(.v5)
|
.swiftLanguageMode(.v5)
|
||||||
]),
|
]),
|
||||||
|
|
|
@ -170,26 +170,17 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
performBackgroundTask { context in
|
performBackgroundTask { context in
|
||||||
let orphanedAttachmentsReq: NSFetchRequest<any NSFetchRequestResult> = DraftAttachment.fetchRequest()
|
|
||||||
orphanedAttachmentsReq.predicate = NSPredicate(format: "draft == nil")
|
|
||||||
let deleteReq = NSBatchDeleteRequest(fetchRequest: orphanedAttachmentsReq)
|
|
||||||
do {
|
|
||||||
try context.execute(deleteReq)
|
|
||||||
} catch {
|
|
||||||
logger.error("Failed to remove orphaned attachments: \(String(describing: error), privacy: .public)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
||||||
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
||||||
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||||
for url in orphanedFiles {
|
for url in orphaned {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: url)
|
try FileManager.default.removeItem(at: url)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
|
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completion()
|
completion()
|
||||||
|
|
|
@ -12,18 +12,7 @@ import Photos
|
||||||
|
|
||||||
struct AttachmentThumbnailView: View {
|
struct AttachmentThumbnailView: View {
|
||||||
let attachment: DraftAttachment
|
let attachment: DraftAttachment
|
||||||
var contentMode: ContentMode = .fit
|
let contentMode: ContentMode = .fit
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
AttachmentThumbnailViewContent(attachment: attachment, contentMode: contentMode)
|
|
||||||
.id(attachment.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentThumbnailViewContent: View {
|
|
||||||
var attachment: DraftAttachment
|
|
||||||
var contentMode: ContentMode = .fit
|
|
||||||
@State private var mode: Mode = .empty
|
@State private var mode: Mode = .empty
|
||||||
@EnvironmentObject private var composeController: ComposeController
|
@EnvironmentObject private var composeController: ComposeController
|
||||||
|
|
||||||
|
@ -31,8 +20,6 @@ private struct AttachmentThumbnailViewContent: View {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .empty:
|
case .empty:
|
||||||
Image(systemName: "photo")
|
Image(systemName: "photo")
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.gray)
|
|
||||||
.task {
|
.task {
|
||||||
await loadThumbnail()
|
await loadThumbnail()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,152 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentCollectionViewCell.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/20/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class AttachmentCollectionViewCell: UICollectionViewCell {
|
|
||||||
let attachmentView: UIView & UIContentView
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
attachmentView = UIHostingConfiguration(content: {
|
|
||||||
AttachmentCollectionViewCellView(attachment: nil)
|
|
||||||
}).makeContentView()
|
|
||||||
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
attachmentView.frame = bounds
|
|
||||||
addSubview(attachmentView)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUI(attachment: DraftAttachment) {
|
|
||||||
attachmentView.configuration = UIHostingConfiguration(content: {
|
|
||||||
AttachmentCollectionViewCellView(attachment: attachment)
|
|
||||||
}).margins(.all, .zero)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentCollectionViewCellView: View {
|
|
||||||
let attachment: DraftAttachment?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let attachment {
|
|
||||||
AttachmentThumbnailView(attachment: attachment, contentMode: .fill)
|
|
||||||
.squareFrame()
|
|
||||||
.background {
|
|
||||||
RoundedSquare(cornerRadius: 5)
|
|
||||||
.fill(.quaternary)
|
|
||||||
}
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
AttachmentDescriptionLabel(attachment: attachment)
|
|
||||||
}
|
|
||||||
.overlay(alignment: .topTrailing) {
|
|
||||||
AttachmentRemoveButton(attachment: attachment)
|
|
||||||
}
|
|
||||||
.clipShape(RoundedSquare(cornerRadius: 5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentRemoveButton: View {
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button("Remove", systemImage: "xmark.circle.fill") {
|
|
||||||
let draft = attachment.draft
|
|
||||||
let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet
|
|
||||||
attachments.remove(attachment)
|
|
||||||
draft.attachments = attachments
|
|
||||||
DraftsPersistentContainer.shared.viewContext.delete(attachment)
|
|
||||||
}
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.shadow(radius: 2)
|
|
||||||
.padding([.top, .trailing], 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentDescriptionLabel: View {
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .bottomLeading) {
|
|
||||||
LinearGradient(
|
|
||||||
stops: [.init(color: .clear, location: 0), .init(color: .clear, location: 0.6), .init(color: .black.opacity(0.5), location: 1)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
|
|
||||||
label
|
|
||||||
.lineLimit(1)
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.shadow(radius: 1)
|
|
||||||
.padding([.horizontal, .bottom], 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var label: some View {
|
|
||||||
if attachment.attachmentDescription.isEmpty {
|
|
||||||
Label("Add alt", systemImage: "pencil")
|
|
||||||
.labelStyle(NarrowSpacingLabelStyle())
|
|
||||||
} else {
|
|
||||||
Text(attachment.attachmentDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NarrowSpacingLabelStyle: LabelStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
configuration.icon
|
|
||||||
configuration.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RoundedSquare: Shape {
|
|
||||||
let cornerRadius: CGFloat
|
|
||||||
|
|
||||||
nonisolated func path(in rect: CGRect) -> Path {
|
|
||||||
let minDimension = min(rect.width, rect.height)
|
|
||||||
let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension)
|
|
||||||
return RoundedRectangle(cornerRadius: cornerRadius).path(in: square)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SquareFrame: Layout {
|
|
||||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
||||||
precondition(subviews.count == 1)
|
|
||||||
let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal))
|
|
||||||
let minDimension = min(size.width, size.height)
|
|
||||||
return CGSize(width: minDimension, height: minDimension)
|
|
||||||
}
|
|
||||||
|
|
||||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
||||||
precondition(subviews.count == 1)
|
|
||||||
let subviewSize = subviews[0].sizeThatFits(proposal)
|
|
||||||
let minDimension = min(bounds.width, bounds.height)
|
|
||||||
let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2)
|
|
||||||
subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
func squareFrame() -> some View {
|
|
||||||
SquareFrame {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,281 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentsSection.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/17/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import PhotosUI
|
|
||||||
import PencilKit
|
|
||||||
|
|
||||||
struct AttachmentsSection: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
WrappedCollectionView(
|
|
||||||
draft: draft,
|
|
||||||
spacing: 8,
|
|
||||||
minItemSize: 100
|
|
||||||
)
|
|
||||||
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
|
|
||||||
// view from laying out, and leaving the intrinsic content size at zero too.
|
|
||||||
.frame(minHeight: 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WrappedCollectionView: UIViewRepresentable {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
let spacing: CGFloat
|
|
||||||
let minItemSize: CGFloat
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> some UIView {
|
|
||||||
let layout = UICollectionViewCompositionalLayout { section, environment in
|
|
||||||
let (itemSize, itemsPerRow) = itemSize(width: environment.container.contentSize.width)
|
|
||||||
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize)))
|
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), repeatingSubitem: item, count: itemsPerRow)
|
|
||||||
group.interItemSpacing = .fixed(spacing)
|
|
||||||
let section = NSCollectionLayoutSection(group: group)
|
|
||||||
section.interGroupSpacing = spacing
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
|
|
||||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: view) { collectionView, indexPath, itemIdentifier in
|
|
||||||
context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier)
|
|
||||||
}
|
|
||||||
dataSource.reorderingHandlers.canReorderItem = { item in
|
|
||||||
switch item {
|
|
||||||
case .attachment(_):
|
|
||||||
true
|
|
||||||
case .addButton:
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.reorderingHandlers.didReorder = { transaction in
|
|
||||||
let attachmentChanges = transaction.difference.map {
|
|
||||||
switch $0 {
|
|
||||||
case .insert(let offset, let element, let associatedWith):
|
|
||||||
guard case .attachment(let attachment) = element else { fatalError() }
|
|
||||||
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
|
|
||||||
case .remove(let offset, let element, let associatedWith):
|
|
||||||
guard case .attachment(let attachment) = element else { fatalError() }
|
|
||||||
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let attachmentsDiff = CollectionDifference(attachmentChanges)!
|
|
||||||
let array = draft.draftAttachments.applying(attachmentsDiff)!
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
context.coordinator.dataSource = dataSource
|
|
||||||
|
|
||||||
view.isScrollEnabled = false
|
|
||||||
view.clipsToBounds = false
|
|
||||||
view.delegate = context.coordinator
|
|
||||||
|
|
||||||
let longPressRecognizer = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(WrappedCollectionViewCoordinator.reorderingLongPressRecognized))
|
|
||||||
longPressRecognizer.delegate = context.coordinator
|
|
||||||
view.addGestureRecognizer(longPressRecognizer)
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
|
||||||
snapshot.appendSections([.all])
|
|
||||||
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
|
|
||||||
snapshot.appendItems([.addButton])
|
|
||||||
context.coordinator.dataSource.apply(snapshot)
|
|
||||||
context.coordinator.addAttachment = {
|
|
||||||
DraftsPersistentContainer.shared.viewContext.insert($0)
|
|
||||||
$0.draft = draft
|
|
||||||
draft.attachments.add($0)
|
|
||||||
}
|
|
||||||
context.coordinator.draft = draft
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> WrappedCollectionViewCoordinator {
|
|
||||||
WrappedCollectionViewCoordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func itemSize(width: CGFloat) -> (CGFloat, Int) {
|
|
||||||
// The maximum item size is 2*minItemSize + spacing - 1,
|
|
||||||
// in the case where one item fits in the row but we are one pt short of
|
|
||||||
// adding a second item.
|
|
||||||
var itemSize = minItemSize
|
|
||||||
var fittingCount = floor((width + spacing) / (itemSize + spacing))
|
|
||||||
var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
|
||||||
var remainingSpace = width - usedSpaceForFittingCount
|
|
||||||
if fittingCount == 1 && remainingSpace > minItemSize / 2 {
|
|
||||||
// If there's only one item that would fit at min size, and giving
|
|
||||||
// it the rest of the space would increase it by at least 50%,
|
|
||||||
// add a second item anywyas.
|
|
||||||
itemSize = (width - spacing) / 2
|
|
||||||
fittingCount = 2
|
|
||||||
usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
|
||||||
remainingSpace = width - usedSpaceForFittingCount
|
|
||||||
}
|
|
||||||
itemSize = itemSize + remainingSpace / fittingCount
|
|
||||||
return (itemSize, Int(fittingCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Section {
|
|
||||||
case all
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Item: Hashable {
|
|
||||||
case attachment(DraftAttachment)
|
|
||||||
case addButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class WrappedCollectionViewCoordinator: NSObject {
|
|
||||||
var draft: Draft!
|
|
||||||
var dataSource: UICollectionViewDiffableDataSource<WrappedCollectionView.Section, WrappedCollectionView.Item>!
|
|
||||||
var currentInteractiveMoveStartOffsetInCell: CGPoint?
|
|
||||||
var currentInteractiveMoveCell: AttachmentCollectionViewCell?
|
|
||||||
var addAttachment: ((DraftAttachment) -> Void)? = nil
|
|
||||||
|
|
||||||
private let attachmentCell = UICollectionView.CellRegistration<AttachmentCollectionViewCell, DraftAttachment> { cell, indexPath, attachment in
|
|
||||||
cell.updateUI(attachment: attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let addButtonCell = UICollectionView.CellRegistration<UICollectionViewCell, (WrappedCollectionViewCoordinator, Bool)> { cell, indexPath, item in
|
|
||||||
let (coordinator, enabled) = item
|
|
||||||
cell.contentConfiguration = UIHostingConfiguration(content: {
|
|
||||||
AddAttachmentButton(coordinator: coordinator, enabled: enabled)
|
|
||||||
}).margins(.all, .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 .addButton:
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: (self, true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
|
|
||||||
let collectionView = recognizer.view as! UICollectionView
|
|
||||||
switch recognizer.state {
|
|
||||||
case .began:
|
|
||||||
break
|
|
||||||
case .changed:
|
|
||||||
var pos = recognizer.location(in: collectionView)
|
|
||||||
if let currentInteractiveMoveStartOffsetInCell {
|
|
||||||
pos.x -= currentInteractiveMoveStartOffsetInCell.x
|
|
||||||
pos.y -= currentInteractiveMoveStartOffsetInCell.y
|
|
||||||
}
|
|
||||||
collectionView.updateInteractiveMovementTargetPosition(pos)
|
|
||||||
case .ended:
|
|
||||||
collectionView.endInteractiveMovement()
|
|
||||||
UIView.animate(withDuration: 0.2) {
|
|
||||||
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
|
|
||||||
}
|
|
||||||
currentInteractiveMoveCell = nil
|
|
||||||
currentInteractiveMoveStartOffsetInCell = nil
|
|
||||||
case .cancelled:
|
|
||||||
collectionView.cancelInteractiveMovement()
|
|
||||||
UIView.animate(withDuration: 0.2) {
|
|
||||||
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
|
|
||||||
}
|
|
||||||
currentInteractiveMoveCell = nil
|
|
||||||
currentInteractiveMoveStartOffsetInCell = nil
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WrappedCollectionViewCoordinator: UIGestureRecognizerDelegate {
|
|
||||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
||||||
let collectionView = gestureRecognizer.view as! UICollectionView
|
|
||||||
let location = gestureRecognizer.location(in: collectionView)
|
|
||||||
guard let indexPath = collectionView.indexPathForItem(at: location),
|
|
||||||
let cell = collectionView.cellForItem(at: indexPath) as? AttachmentCollectionViewCell else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
UIView.animate(withDuration: 0.2) {
|
|
||||||
cell.attachmentView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
|
||||||
}
|
|
||||||
currentInteractiveMoveCell = cell
|
|
||||||
currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell)
|
|
||||||
currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX
|
|
||||||
currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY
|
|
||||||
return collectionView.beginInteractiveMovementForItem(at: indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WrappedCollectionViewCoordinator: UICollectionViewDelegate {
|
|
||||||
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
|
|
||||||
let snapshot = dataSource.snapshot()
|
|
||||||
let items = snapshot.itemIdentifiers(inSection: .all).count
|
|
||||||
if proposedIndexPath.row == items - 1 {
|
|
||||||
return IndexPath(item: items - 2, section: proposedIndexPath.section)
|
|
||||||
} else {
|
|
||||||
return proposedIndexPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AddAttachmentButton: View {
|
|
||||||
let coordinator: WrappedCollectionViewCoordinator
|
|
||||||
let enabled: Bool
|
|
||||||
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
|
|
||||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Menu {
|
|
||||||
if let presentAssetPicker {
|
|
||||||
Button("Add photo or video", systemImage: "photo") {
|
|
||||||
presentAssetPicker {
|
|
||||||
let draft = coordinator.draft!
|
|
||||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let presentDrawing {
|
|
||||||
Button("Draw something", systemImage: "hand.draw") {
|
|
||||||
presentDrawing(PKDrawing()) { drawing in
|
|
||||||
let draft = coordinator.draft!
|
|
||||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
|
||||||
attachment.id = UUID()
|
|
||||||
attachment.drawing = drawing
|
|
||||||
attachment.draft = draft
|
|
||||||
draft.attachments.add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "photo.badge.plus")
|
|
||||||
.imageScale(.large)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.foregroundStyle(.tint.opacity(0.1))
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(!enabled)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -128,7 +128,7 @@ private struct TogglePollButton: View {
|
||||||
|
|
||||||
struct AddAttachmentConditionsModifier: ViewModifier {
|
struct AddAttachmentConditionsModifier: ViewModifier {
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: Draft
|
||||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
@ObservedObject var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
private var canAddAttachment: Bool {
|
private var canAddAttachment: Bool {
|
||||||
if instanceFeatures.mastodonAttachmentRestrictions {
|
if instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
|
|
@ -1,59 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeDraftView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/16/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Pachyderm
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
struct ComposeDraftView: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
@Environment(\.currentAccount) private var currentAccount
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
// TODO: scroll effect?
|
|
||||||
AvatarImageView(
|
|
||||||
url: currentAccount?.avatar,
|
|
||||||
size: 50,
|
|
||||||
style: controller.config.avatarStyle,
|
|
||||||
fetchAvatar: controller.fetchAvatar
|
|
||||||
)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
if let currentAccount {
|
|
||||||
AccountNameView(account: currentAccount)
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
|
||||||
|
|
||||||
DraftContentEditor(draft: draft, focusedField: $focusedField)
|
|
||||||
|
|
||||||
AttachmentsSection(draft: draft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AccountNameView: View {
|
|
||||||
let account: any AccountProtocol
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
controller.displayNameLabel(account, .body, 16)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(verbatim: "@\(account.acct)")
|
|
||||||
.font(.body.weight(.light))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -48,6 +48,8 @@ struct ComposeToolbarView: View {
|
||||||
FormatButtons()
|
FormatButtons()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
LangaugeButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -237,6 +239,34 @@ private struct FormatButton: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct LangaugeButton: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@ObservedObject var instanceFeatures: InstanceFeatures
|
||||||
|
@FocusedValue(\.composeInput) private var input
|
||||||
|
@State private var hasChanged = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if instanceFeatures.createStatusWithLanguage {
|
||||||
|
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
|
||||||
|
.onChange(of: draft.id) { _ in
|
||||||
|
hasChanged = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
private func currentInputModeChanged(_ notification: Foundation.Notification) {
|
||||||
|
guard !hasChanged,
|
||||||
|
!draft.hasContent,
|
||||||
|
let mode = input?.textInputMode,
|
||||||
|
let code = LanguagePicker.codeFromInputMode(mode) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.language = code.identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//#Preview {
|
//#Preview {
|
||||||
// ComposeToolbarView()
|
// ComposeToolbarView()
|
||||||
//}
|
//}
|
||||||
|
|
|
@ -18,14 +18,14 @@ struct ComposeView: View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
navigationRoot
|
navigationRoot
|
||||||
}
|
}
|
||||||
.environmentObject(mastodonController.instanceFeatures)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var navigationRoot: some View {
|
private var navigationRoot: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ScrollView {
|
List {
|
||||||
scrollContent
|
listContent
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
||||||
.modifier(ToolbarSafeAreaInsetModifier())
|
.modifier(ToolbarSafeAreaInsetModifier())
|
||||||
|
@ -49,7 +49,7 @@ struct ComposeView: View {
|
||||||
#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
|
||||||
.modifier(DropAttachmentModifier(draft: draft))
|
.modifier(DropAttachmentModifier(draft: draft))
|
||||||
.modifier(AddAttachmentConditionsModifier(draft: draft))
|
.modifier(AddAttachmentConditionsModifier(draft: draft, instanceFeatures: mastodonController.instanceFeatures))
|
||||||
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
@ -67,26 +67,24 @@ struct ComposeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var scrollContent: some View {
|
private var listContent: some View {
|
||||||
VStack(spacing: 8) {
|
|
||||||
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
|
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
ComposeDraftView(draft: draft, focusedField: $focusedField)
|
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
}
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
.padding(8)
|
.listRowSeparator(.hidden)
|
||||||
// NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
|
||||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
||||||
// .listRowSeparator(.hidden)
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
//
|
.listRowSeparator(.hidden)
|
||||||
// ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
|
||||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
||||||
// .listRowSeparator(.hidden)
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
//
|
.listRowSeparator(.hidden)
|
||||||
// NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
|
||||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
// .listRowSeparator(.hidden)
|
|
||||||
//
|
|
||||||
// AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addAttachments(_ itemProviders: [NSItemProvider]) {
|
private func addAttachments(_ itemProviders: [NSItemProvider]) {
|
||||||
|
|
|
@ -1,96 +0,0 @@
|
||||||
//
|
|
||||||
// DraftContentEditor.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/16/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import InstanceFeatures
|
|
||||||
|
|
||||||
struct DraftContentEditor: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline) {
|
|
||||||
LanguageButton(draft: draft)
|
|
||||||
Spacer()
|
|
||||||
CharactersRemaining(draft: draft)
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
}
|
|
||||||
.padding(.all.subtracting(.top), 2)
|
|
||||||
}
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addAttachments(_ providers: [NSItemProvider]) {
|
|
||||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: providers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CharactersRemaining: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@EnvironmentObject var instanceFeatures: InstanceFeatures
|
|
||||||
|
|
||||||
private var charsRemaining: Int {
|
|
||||||
let limit = instanceFeatures.maxStatusChars
|
|
||||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
|
||||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(verbatim: charsRemaining.description)
|
|
||||||
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
|
|
||||||
.font(.callout.monospacedDigit())
|
|
||||||
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LanguageButton: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
|
||||||
@FocusedValue(\.composeInput) private var input
|
|
||||||
@State private var hasChanged = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if instanceFeatures.createStatusWithLanguage {
|
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
|
|
||||||
.buttonStyle(LanguageButtonStyle())
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
|
|
||||||
.onChange(of: draft.id) { _ in
|
|
||||||
hasChanged = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentInputModeChanged(_ notification: Foundation.Notification) {
|
|
||||||
guard !hasChanged,
|
|
||||||
!draft.hasContent,
|
|
||||||
let mode = input?.textInputMode,
|
|
||||||
let code = LanguagePicker.codeFromInputMode(mode) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.language = code.identifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LanguageButtonStyle: ButtonStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
configuration.label
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
|
|
||||||
.animation(.linear(duration: 0.1), value: configuration.isPressed)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -51,10 +51,9 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
.foregroundColor: UIColor.label,
|
.foregroundColor: UIColor.label,
|
||||||
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||||
]
|
]
|
||||||
view.backgroundColor = nil
|
|
||||||
|
|
||||||
// view.layer.cornerRadius = 5
|
view.layer.cornerRadius = 5
|
||||||
// view.layer.cornerCurve = .continuous
|
view.layer.cornerCurve = .continuous
|
||||||
// view.layer.shadowColor = UIColor.black.cgColor
|
// view.layer.shadowColor = UIColor.black.cgColor
|
||||||
// view.layer.shadowOpacity = 0.15
|
// view.layer.shadowOpacity = 0.15
|
||||||
// view.layer.shadowOffset = .zero
|
// view.layer.shadowOffset = .zero
|
||||||
|
@ -76,9 +75,9 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
|
|
||||||
uiView.isEditable = isEnabled
|
uiView.isEditable = isEnabled
|
||||||
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
||||||
// #if !os(visionOS)
|
#if !os(visionOS)
|
||||||
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||||
// #endif
|
#endif
|
||||||
|
|
||||||
// Trying to set this with the @FocusState binding in onAppear results in the
|
// Trying to set this with the @FocusState binding in onAppear results in the
|
||||||
// keyboard not appearing until after the sheet presentation animation completes :/
|
// keyboard not appearing until after the sheet presentation animation completes :/
|
||||||
|
|
Loading…
Reference in New Issue