diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift index 9d6831cda..8e3c46410 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -66,29 +66,29 @@ enum ChatPhotoStaging { } /// Returns the image `provider` carries, or `nil` when it carries none, cannot be loaded or - /// cannot be decoded. Dropped images take the same decode as picked ones, so a full-size + /// cannot be decoded. Dropped and pasted images take the same decode as picked ones, so a full-size /// screenshot or photo does not sit in memory as an undecoded bitmap. static func loadImage(_ provider: NSItemProvider) async -> UIImage? { guard provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else { return nil } let data: Data? = await withCheckedContinuation { continuation in _ = provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, error in if let error { - logger.warning("Dropped image failed to load", metadata: ["error": "\(error)"]) + logger.warning("Provided image failed to load", metadata: ["error": "\(error)"]) } continuation.resume(returning: data) } } guard let data else { return nil } guard let image = await decode(data) else { - logger.warning("Dropped image failed to decode", metadata: ["bytes": "\(data.count)"]) + logger.warning("Provided image failed to decode", metadata: ["bytes": "\(data.count)"]) return nil } return image } - /// Stages the images `providers` carry, in drop order, stopping at a full composer. + /// Stages the images dropped or pasted `providers` carry, in order, stopping at a full composer. @discardableResult - static func stageDropped( + static func stageProvided( _ providers: [NSItemProvider], into composer: ComposerModel, uploader: ChatMediaUploader diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 14acbcbb1..768c026ea 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -120,6 +120,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var onPhotosAdded: ([PhotosPickerItem], ChatPhotoPreloader) -> ComposerChip.ID? = { _, _ in nil } /// Receives images dropped on the bar. var onImagesDropped: ([NSItemProvider]) -> Void = { _ in } + /// Receives images pasted into the composer's field. + var onImagesPasted: ([NSItemProvider]) -> Void = { _ in } /// Mints a signed download URL for a photo in this chat. The transcript's resolver caches what it /// returns and never asks for a photo drawn only from its BlurHash. var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } @@ -155,6 +157,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.linkCardSource = linkCardSource screen.webPreviewMode = webPreviewMode screen.onMediaTap = onMediaTap + screen.acceptsPastedImages = acceptsMedia && editingStableID == nil + screen.onImagesPasted = onImagesPasted context.coordinator.mintMediaURL = mintMediaURL context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt screen.mediaURLResolver = context.coordinator.mediaURLResolver @@ -209,6 +213,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.linkCardSource = linkCardSource screen.webPreviewMode = webPreviewMode screen.onMediaTap = onMediaTap + screen.acceptsPastedImages = acceptsMedia && editingStableID == nil + screen.onImagesPasted = onImagesPasted context.coordinator.mintMediaURL = mintMediaURL context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt screen.onEncryptionMarkerTap = onEncryptionMarkerTap diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index f1b3ed0b0..157f1dbaa 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -423,7 +423,8 @@ struct ConversationScreen: View { acceptsMedia: acceptsMedia, onCameraCapture: stageCapturedPhoto, onPhotosAdded: stageAddedPhotos, - onImagesDropped: stageDroppedImages, + onImagesDropped: stageProvidedImages, + onImagesPasted: stageProvidedImages, mintMediaURL: mintMediaURL, mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer @@ -463,15 +464,15 @@ struct ConversationScreen: View { ).handOff } - /// Stages images dropped on the composer, in drop order, as if picked from the library. The - /// bar refuses the drop up front when the chat takes no photos; the gate here covers a chat - /// that stopped taking them between the drop and this call. - private func stageDroppedImages(_ providers: [NSItemProvider]) { + /// Stages images dropped on or pasted into the composer, in order, as if picked from the + /// library. The bar refuses them up front when the chat takes no photos; the gate here covers a + /// chat that stopped taking them between the drop or paste and this call. + private func stageProvidedImages(_ providers: [NSItemProvider]) { guard acceptsMedia else { return } let composer = composer let uploader = mediaUploader Task { - await ChatPhotoStaging.stageDropped(providers, into: composer, uploader: uploader) + await ChatPhotoStaging.stageProvided(providers, into: composer, uploader: uploader) } } diff --git a/FlipcashTests/Chat/ChatPhotoStagingTests.swift b/FlipcashTests/Chat/ChatPhotoStagingTests.swift index f64836696..ce9f7962d 100644 --- a/FlipcashTests/Chat/ChatPhotoStagingTests.swift +++ b/FlipcashTests/Chat/ChatPhotoStagingTests.swift @@ -157,7 +157,7 @@ struct ChatPhotoStagingTests { NSItemProvider(object: picked[1]), ] - let staged = await ChatPhotoStaging.stageDropped(providers, into: composer, uploader: uploader) + let staged = await ChatPhotoStaging.stageProvided(providers, into: composer, uploader: uploader) #expect(staged.count == 2) #expect(composer.chips.map(\.image.size) == picked.map(\.size)) @@ -167,7 +167,7 @@ struct ChatPhotoStagingTests { func droppedTextStagesNothing() async { let composer = ComposerModel() - await ChatPhotoStaging.stageDropped([NSItemProvider(object: "text" as NSString)], into: composer, uploader: uploader) + await ChatPhotoStaging.stageProvided([NSItemProvider(object: "text" as NSString)], into: composer, uploader: uploader) #expect(composer.chips.isEmpty) } @@ -177,7 +177,7 @@ struct ChatPhotoStagingTests { let composer = ComposerModel() let providers = images(ComposerModel.maxAttachments + 2).map { NSItemProvider(object: $0) } - await ChatPhotoStaging.stageDropped(providers, into: composer, uploader: uploader) + await ChatPhotoStaging.stageProvided(providers, into: composer, uploader: uploader) #expect(composer.chips.count == ComposerModel.maxAttachments) } diff --git a/FlipcashTests/Chat/ComposerImagePasteHandlerTests.swift b/FlipcashTests/Chat/ComposerImagePasteHandlerTests.swift new file mode 100644 index 000000000..242d63029 --- /dev/null +++ b/FlipcashTests/Chat/ComposerImagePasteHandlerTests.swift @@ -0,0 +1,132 @@ +// +// ComposerImagePasteHandlerTests.swift +// FlipcashTests +// + +import Testing +import UIKit +@testable import FlipcashUI + +@MainActor +@Suite("Composer image paste") +struct ComposerImagePasteHandlerTests { + + @Test("A pasted image leaves the text and is handed on") + func imageHandedOn() async { + let handler = ComposerImagePasteHandler() + handler.acceptsImages = true + var received: [[NSItemProvider]] = [] + handler.onImages = { received.append($0) } + let item = FakePasteItem(.image) + + handler.take(item) + await Task.yield() + + #expect(item.result == .none) + #expect(received.count == 1) + #expect(received.first?.first === item.itemProvider) + } + + @Test("Pasted text pastes as usual and nothing is handed on") + func textPastesAsUsual() async { + let handler = ComposerImagePasteHandler() + handler.acceptsImages = true + var received: [[NSItemProvider]] = [] + handler.onImages = { received.append($0) } + let item = FakePasteItem(.text) + + handler.take(item) + await Task.yield() + + #expect(item.result == .default) + #expect(received.isEmpty) + } + + @Test("A paste of several items hands its images on together, in order, and keeps the text") + func mixedPasteBatches() async { + let handler = ComposerImagePasteHandler() + handler.acceptsImages = true + var received: [[NSItemProvider]] = [] + handler.onImages = { received.append($0) } + let first = FakePasteItem(.image) + let text = FakePasteItem(.text) + let second = FakePasteItem(.image) + + handler.take(first) + handler.take(text) + handler.take(second) + await Task.yield() + + #expect(text.result == .default) + #expect(received.count == 1) + #expect(received.first?.map(ObjectIdentifier.init) == [first, second].map { ObjectIdentifier($0.itemProvider) }) + } + + @Test("When images are not accepted a pasted image is dropped, not put in the text") + func imageDroppedWhenNotAccepted() async { + let handler = ComposerImagePasteHandler() + var received: [[NSItemProvider]] = [] + handler.onImages = { received.append($0) } + let item = FakePasteItem(.image) + + handler.take(item) + await Task.yield() + + #expect(item.result == .none) + #expect(received.isEmpty) + } + + @Test("Pasted text drops its source styling and takes the field's") + func pastedTextIsPlain() { + let handler = ComposerImagePasteHandler() + let styled = NSAttributedString(string: "bold", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 30), + .foregroundColor: UIColor.black, + ]) + let fieldStyle: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.white] + + let result = handler.plainText([styled, NSAttributedString(string: " text")], attributes: fieldStyle) + + #expect(result.string == "bold text") + let attributes = result.attributes(at: 0, effectiveRange: nil) + #expect(attributes[.font] == nil) + #expect(attributes[.foregroundColor] as? UIColor == .white) + } + + @Test("Installing turns on attributed editing and routes paste to the handler") + func installConfiguresTextView() { + let handler = ComposerImagePasteHandler() + let textView = UITextView() + + handler.install(on: textView) + + #expect(textView.allowsEditingTextAttributes) + #expect(textView.pasteDelegate === handler) + #expect(textView.pasteConfiguration?.acceptableTypeIdentifiers.contains("public.image") == true) + } +} + +private final class FakePasteItem: NSObject, UITextPasteItem { + enum Kind { case image, text } + enum Result { case unset, none, `default`, other } + + let itemProvider: NSItemProvider + let localObject: Any? = nil + let defaultAttributes: [NSAttributedString.Key: Any] = [:] + private(set) var result = Result.unset + + init(_ kind: Kind) { + switch kind { + case .image: + itemProvider = NSItemProvider(object: UIImage(systemName: "photo")!) + case .text: + itemProvider = NSItemProvider(object: "hello" as NSString) + } + } + + func setResult(string: String) { result = .other } + func setResult(attributedString: NSAttributedString) { result = .other } + func setResult(attachment: NSTextAttachment) { result = .other } + func setNoResult() { result = .none } + func setDefaultResult() { result = .default } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index f9f6f1c23..2d448c97c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -329,9 +329,32 @@ public final class ChatScreenViewController: UIViewController { /// The layer over the transcript that takes its touches, if one is up. private var transcriptShield: TranscriptShield? + private let imagePaste = ComposerImagePasteHandler() + + /// Whether images pasted into the composer are handed to `onImagesPasted`. When false a pasted + /// image is dropped rather than put in the text. + public var acceptsPastedImages: Bool { + get { imagePaste.acceptsImages } + set { imagePaste.acceptsImages = newValue } + } + + /// Receives the images one paste into the composer carried, in paste order. + public var onImagesPasted: (([NSItemProvider]) -> Void)? { + get { imagePaste.onImages } + set { imagePaste.onImages = newValue } + } + public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(Color.backgroundMain) + // SwiftUI can rebuild the composer's field, so the paste handler goes on whichever one + // starts editing. + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidBeginEditing(_:)), + name: UITextView.textDidBeginEditingNotification, + object: nil + ) addChild(transcript) transcript.view.translatesAutoresizingMaskIntoConstraints = false @@ -690,6 +713,21 @@ public final class ChatScreenViewController: UIViewController { return responder is UITextView || responder is UITextField } + /// Takes Format out of the composer's edit menu. The field edits attributed text only so the + /// keyboard offers pasted images, and the chat sends plain text. + public override func buildMenu(with builder: any UIMenuBuilder) { + super.buildMenu(with: builder) + guard builder.system == .context else { return } + builder.remove(menu: .format) + } + + @objc private func textDidBeginEditing(_ notification: Notification) { + guard let textView = notification.object as? UITextView, + textView === bar.firstTextInputResponder, + textView.pasteDelegate !== imagePaste else { return } + imagePaste.install(on: textView) + } + /// Shows `inputView` in the keyboard's place under the composer's field, or the system keyboard /// again for `nil`, without the field losing focus. Returns whether the field took it. @discardableResult diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerImagePasteHandler.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerImagePasteHandler.swift new file mode 100644 index 000000000..30d402998 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerImagePasteHandler.swift @@ -0,0 +1,74 @@ +import UIKit +import UniformTypeIdentifiers + +/// Takes images pasted into the composer's field out of the text and hands them on as photos, +/// leaving everything else to paste as usual. +@MainActor +final class ComposerImagePasteHandler: NSObject, UITextPasteDelegate { + + /// Whether pasted images are handed on; when false they are dropped, never put in the text. + var acceptsImages = false + + /// Receives the images one paste carried, in paste order. + var onImages: (([NSItemProvider]) -> Void)? + + private var pending: [NSItemProvider] = [] + + /// Makes `textView` take pasted images and route them here. The keyboard's paste suggestion + /// only offers an image to a field that edits attributed text, so this turns that on too. + func install(on textView: UITextView) { + textView.allowsEditingTextAttributes = true + // Attributed editing is what the suggestion was seen to need; the image type here matches + // the configuration it was seen working with. + var types = textView.pasteConfiguration?.acceptableTypeIdentifiers ?? [] + if !types.contains(UTType.image.identifier) { + types.append(UTType.image.identifier) + textView.pasteConfiguration = UIPasteConfiguration(acceptableTypeIdentifiers: types) + } + textView.pasteDelegate = self + } + + func textPasteConfigurationSupporting( + _ textPasteConfigurationSupporting: any UITextPasteConfigurationSupporting, + transform item: any UITextPasteItem + ) { + take(item) + } + + func textPasteConfigurationSupporting( + _ textPasteConfigurationSupporting: any UITextPasteConfigurationSupporting, + combineItemAttributedStrings itemStrings: [NSAttributedString], + for textRange: UITextRange + ) -> NSAttributedString { + let typingAttributes = (textPasteConfigurationSupporting as? UITextView)?.typingAttributes ?? [:] + return plainText(itemStrings, attributes: typingAttributes) + } + + /// Joins pasted text with its source styling dropped, in the field's own style. The chat sends + /// plain text, so styling pasted into the field would only show until it was sent. + func plainText(_ itemStrings: [NSAttributedString], attributes: [NSAttributedString.Key: Any]) -> NSAttributedString { + NSAttributedString(string: itemStrings.map(\.string).joined(), attributes: attributes) + } + + /// Routes one pasted item: an image leaves the text, anything else pastes as it would have. + func take(_ item: any UITextPasteItem) { + guard item.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else { + item.setDefaultResult() + return + } + item.setNoResult() + guard acceptsImages else { return } + // Each item of a paste arrives on its own; collect them so one paste stages as one batch. + if pending.isEmpty { + Task { @MainActor in self.flush() } + } + pending.append(item.itemProvider) + } + + private func flush() { + let providers = pending + pending = [] + guard !providers.isEmpty else { return } + onImages?(providers) + } +}