From 8538de6ada4e1aa4349001df356189b1fa92764b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 22:50:24 -0400 Subject: [PATCH] feat(chat): paste images into the composer Pasted images stage as photo chips through the same path as images dropped on the bar. This covers long-press Paste and the iOS 27 "Paste from Photos" keyboard suggestion. The suggestion only offers an image to a text view that edits attributed text, so the composer's field now has allowsEditingTextAttributes on. Without it, tapping the suggestion does nothing. The chat still sends plain text, so the screen removes Format from the edit menu and pasted text drops its source styling. When the chat takes no photos, or a message is being edited, a pasted image is dropped rather than put in the text. --- .../Conversation/ChatPhotoStaging.swift | 10 +- .../ChatScreenRepresentable.swift | 6 + .../Conversation/ConversationScreen.swift | 13 +- .../Chat/ChatPhotoStagingTests.swift | 6 +- .../Chat/ComposerImagePasteHandlerTests.swift | 132 ++++++++++++++++++ .../Chat/ChatScreenViewController.swift | 38 +++++ .../Chat/ComposerImagePasteHandler.swift | 74 ++++++++++ 7 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 FlipcashTests/Chat/ComposerImagePasteHandlerTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ComposerImagePasteHandler.swift 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) + } +}