Compare commits

..

3 Commits

11 changed files with 648 additions and 64 deletions

View File

@ -21,13 +21,14 @@ 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"], dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences", "UserAccounts"],
swiftSettings: [ swiftSettings: [
.swiftLanguageMode(.v5) .swiftLanguageMode(.v5)
]), ]),

View File

@ -170,17 +170,26 @@ 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 orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL)) let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphaned { for url in orphanedFiles {
do { do {
try FileManager.default.removeItem(at: url) try FileManager.default.removeItem(at: url)
} catch { } catch {
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)") logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
} }
} }
completion() completion()

View File

@ -12,7 +12,18 @@ import Photos
struct AttachmentThumbnailView: View { struct AttachmentThumbnailView: View {
let attachment: DraftAttachment let attachment: DraftAttachment
let contentMode: ContentMode = .fit var 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
@ -20,6 +31,8 @@ struct AttachmentThumbnailView: 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()
} }

View File

@ -0,0 +1,152 @@
//
// 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
}
}
}

View File

@ -0,0 +1,281 @@
//
// 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)
}
}

View File

@ -128,7 +128,7 @@ private struct TogglePollButton: View {
struct AddAttachmentConditionsModifier: ViewModifier { struct AddAttachmentConditionsModifier: ViewModifier {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures @EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddAttachment: Bool { private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions { if instanceFeatures.mastodonAttachmentRestrictions {

View File

@ -0,0 +1,59 @@
//
// 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)
}
}
}

View File

@ -48,8 +48,6 @@ struct ComposeToolbarView: View {
FormatButtons() FormatButtons()
Spacer() Spacer()
LangaugeButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
} }
} }
} }
@ -239,34 +237,6 @@ 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()
//} //}

View File

@ -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 {
List { ScrollView {
listContent scrollContent
} }
.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, instanceFeatures: mastodonController.instanceFeatures)) .modifier(AddAttachmentConditionsModifier(draft: draft))
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -67,24 +67,26 @@ struct ComposeView: View {
} }
@ViewBuilder @ViewBuilder
private var listContent: some View { private var scrollContent: some View {
NewReplyStatusView(draft: draft, mastodonController: mastodonController) VStack(spacing: 8) {
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) NewReplyStatusView(draft: draft, mastodonController: mastodonController)
.listRowSeparator(.hidden)
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures) ComposeDraftView(draft: draft, focusedField: $focusedField)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) }
.listRowSeparator(.hidden) .padding(8)
// NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
ContentWarningTextField(draft: draft, focusedField: $focusedField) // .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) // .listRowSeparator(.hidden)
.listRowSeparator(.hidden) //
// ContentWarningTextField(draft: draft, focusedField: $focusedField)
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments) // .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) // .listRowSeparator(.hidden)
.listRowSeparator(.hidden) //
// NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures) // .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
// .listRowSeparator(.hidden)
//
// AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
} }
private func addAttachments(_ itemProviders: [NSItemProvider]) { private func addAttachments(_ itemProviders: [NSItemProvider]) {

View File

@ -0,0 +1,96 @@
//
// 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)
}
}

View File

@ -51,9 +51,10 @@ 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
@ -75,9 +76,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 :/