Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
var onPhotosAdded: ([PhotosPickerItem], ChatPhotoPreloader<PhotosPickerItem>) -> 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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
6 changes: 3 additions & 3 deletions FlipcashTests/Chat/ChatPhotoStagingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
132 changes: 132 additions & 0 deletions FlipcashTests/Chat/ComposerImagePasteHandlerTests.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
38 changes: 38 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ComposerImagePasteHandler.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading