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
34 changes: 33 additions & 1 deletion Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhotosPickerItem>) -> 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 }
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhotosPickerItem>) -> 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
13 changes: 13 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ struct ConversationScreen: View {
acceptsMedia: acceptsMedia,
onCameraCapture: stageCapturedPhoto,
onPhotosAdded: stageAddedPhotos,
onImagesDropped: stageDroppedImages,
mintMediaURL: mintMediaURL,
mediaBlobDecrypt: mediaBlobDecrypt,
onMediaTap: openMediaViewer
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions FlipcashTests/Chat/ChatPhotoStagingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading