diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift index afe0f5979..9d6831cda 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -8,11 +8,12 @@ import PhotosUI import SwiftUI import UIKit +import UniformTypeIdentifiers import FlipcashCore private let logger = Logger(label: "flipcash.chat-photo-staging") -/// Turns photos picked from the library into composer chips. +/// Turns photos picked from the library, or dropped on the composer, into composer chips. enum ChatPhotoStaging { /// Loads `items` one at a time in pick order and stages each photo that loads, stopping at the @@ -64,6 +65,37 @@ enum ChatPhotoStaging { return (handOff, remainder) } + /// 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 + /// 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)"]) + } + 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)"]) + return nil + } + return image + } + + /// Stages the images `providers` carry, in drop order, stopping at a full composer. + @discardableResult + static func stageDropped( + _ providers: [NSItemProvider], + into composer: ComposerModel, + uploader: ChatMediaUploader + ) async -> [ComposerChip] { + await stage(providers, into: composer, uploader: uploader, load: loadImage(_:)) + } + /// Returns the photo `item` holds, or `nil` when it cannot be loaded or decoded. static func loadImage(_ item: PhotosPickerItem) async -> UIImage? { let data: Data diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 63bd84b0d..14acbcbb1 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -118,6 +118,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// selected, with the loader already reading them. Returns the chip the first was staged as, /// which the card shrinks into, when it could be staged at once. var onPhotosAdded: ([PhotosPickerItem], ChatPhotoPreloader) -> ComposerChip.ID? = { _, _ in nil } + /// Receives images dropped on the bar. + var onImagesDropped: ([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 } @@ -331,6 +333,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onPhotosAdd: { [weak coordinator] items, preloader in coordinator?.closeCard { onPhotosAdded(items, preloader) } }, + onImagesDropped: onImagesDropped, onPhotosBack: { [model = barModel] in // Back into the panel, with the keyboard left down. model.returnToMenu() diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index a9251f296..3a9952455 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -286,6 +286,9 @@ struct ConversationBottomBar: View { /// Receives the photos added from the photo card, in the order they were selected, with the /// loader already reading them. var onPhotosAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void = { _, _ in } + /// Receives images dropped on the bar, such as the system screenshot thumbnail or an image + /// from another app in split view. Only fired while the bar takes photos. + var onImagesDropped: ([NSItemProvider]) -> Void = { _ in } /// Fired by the photo card's back chevron and escape gesture. var onPhotosBack: () -> Void = {} /// Where the reply strip's quoted photo loads its thumbnail from. @@ -467,6 +470,13 @@ struct ConversationBottomBar: View { .animation(Self.widthSpring, value: isCompact) .padding(.top, BarMetrics.contentPadding) .padding(.bottom, BarMetrics.contentPadding) + // The whole bar, margins included, takes a dropped image. Images only: the field's own + // text drop keeps plain text, and declines an image session, so it falls to this. + .contentShape(Rectangle()) + .onDrop(of: [.image], delegate: ComposerImageDropDelegate( + acceptsDrop: acceptsMedia && !composer.isEditing, + onDrop: onImagesDropped + )) .animation(barMorphSpring, value: composer.isEditing) // The strip arriving with its first chip and leaving with its last, on the chip spring unless // a capture's animation is already carrying it. @@ -1396,3 +1406,27 @@ private struct CancelEditButton: View { .accessibilityIdentifier("cancel-edit-button") } } + + +/// Takes images dropped on the composer. A chat that does not take photos refuses the drop with a +/// forbidden proposal, so the system shows it as refused instead of letting it land silently. +struct ComposerImageDropDelegate: DropDelegate { + let acceptsDrop: Bool + let onDrop: ([NSItemProvider]) -> Void + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.image]) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: acceptsDrop ? .copy : .forbidden) + } + + func performDrop(info: DropInfo) -> Bool { + guard acceptsDrop else { return false } + let providers = info.itemProviders(for: [.image]) + guard !providers.isEmpty else { return false } + onDrop(providers) + return true + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 71f34203c..f1b3ed0b0 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -423,6 +423,7 @@ struct ConversationScreen: View { acceptsMedia: acceptsMedia, onCameraCapture: stageCapturedPhoto, onPhotosAdded: stageAddedPhotos, + onImagesDropped: stageDroppedImages, mintMediaURL: mintMediaURL, mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer @@ -462,6 +463,18 @@ 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]) { + guard acceptsMedia else { return } + let composer = composer + let uploader = mediaUploader + Task { + await ChatPhotoStaging.stageDropped(providers, into: composer, uploader: uploader) + } + } + /// Stages a photo taken with the inline camera, returning the chip it became. private func stageCapturedPhoto(_ capture: ChatCameraCapture) -> ComposerChip.ID? { composer.stageChip(image: capture.image, preview: capture.preview, uploader: mediaUploader)?.id diff --git a/FlipcashTests/Chat/ChatPhotoStagingTests.swift b/FlipcashTests/Chat/ChatPhotoStagingTests.swift index 0e5614ed3..f64836696 100644 --- a/FlipcashTests/Chat/ChatPhotoStagingTests.swift +++ b/FlipcashTests/Chat/ChatPhotoStagingTests.swift @@ -147,6 +147,41 @@ struct ChatPhotoStagingTests { #expect(composer.chips.last?.image == picked[0]) } + @Test("Dropped images stage as chips, and a non-image provider does not") + func droppedImagesStage() async { + let composer = ComposerModel() + let picked = images(2) + let providers = [ + NSItemProvider(object: picked[0]), + NSItemProvider(object: "just text" as NSString), + NSItemProvider(object: picked[1]), + ] + + let staged = await ChatPhotoStaging.stageDropped(providers, into: composer, uploader: uploader) + + #expect(staged.count == 2) + #expect(composer.chips.map(\.image.size) == picked.map(\.size)) + } + + @Test("A dropped non-image provider stages nothing") + func droppedTextStagesNothing() async { + let composer = ComposerModel() + + await ChatPhotoStaging.stageDropped([NSItemProvider(object: "text" as NSString)], into: composer, uploader: uploader) + + #expect(composer.chips.isEmpty) + } + + @Test("Dropping stops at a full composer") + func droppedStopsWhenFull() async { + let composer = ComposerModel() + let providers = images(ComposerModel.maxAttachments + 2).map { NSItemProvider(object: $0) } + + await ChatPhotoStaging.stageDropped(providers, into: composer, uploader: uploader) + + #expect(composer.chips.count == ComposerModel.maxAttachments) + } + @Test("A photo with a sideways EXIF orientation decodes with the turn drawn into its pixels") func decodeBakesOrientation() async throws { let data = NSMutableData()