From 248f271816b12d613018aaa0ed77bacc134aeb31 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:42:14 -0400 Subject: [PATCH 1/2] feat(chat): send and show photo messages Photos are staged as composer chips, uploaded through the downscale and quality ladder, and sent one message each with retry. The transcript draws photo bubbles, quotes and previews describe them, and tapping one opens a full-screen viewer. An inline camera captures straight into the composer. Only the downsampled copy is cached, not the original. --- .github/workflows/vectors.yml | 9 +- .../Controllers/ConversationController.swift | 174 +++++++++- .../Controllers/EncryptedChatClient.swift | 5 + .../Controllers/FlipClient+Protocols.swift | 2 + .../Screens/Conversation/AttachMenu.swift | 100 ++++++ .../Conversation/ChatCameraSheet.swift | 198 +++++++++++ .../Screens/Conversation/ChatCameraSlot.swift | 72 ++++ .../Conversation/ChatItem+Conversation.swift | 18 +- .../Conversation/ChatMediaSendPlan.swift | 54 +++ .../Conversation/ChatMediaUploader.swift | 219 ++++++++++++ .../Conversation/ChatPhotoCamera.swift | 186 ++++++++++ .../Conversation/ChatPhotoStaging.swift | 62 ++++ .../ChatScreenRepresentable.swift | 52 ++- .../Screens/Conversation/ComposerChip.swift | 90 +++++ .../Conversation/ComposerChipStrip.swift | 152 +++++++++ .../Screens/Conversation/ComposerModel.swift | 74 +++- .../Conversation/ComposerReplyStrip.swift | 35 ++ .../Conversation/ConversationBottomBar.swift | 179 +++++++--- .../Conversation/ConversationScreen.swift | 59 +++- .../Core/Screens/Main/CameraPromptView.swift | 4 +- .../Core/Spotlight/ChatSpotlightItem.swift | 2 +- .../Clients/Flip API/FlipClient+Chat.swift | 8 + .../Services/ChatMessagingService.swift | 33 +- .../Models/Chat/ChatMediaStrings.swift | 29 ++ .../Models/Chat/ChatMessage.swift | 41 ++- .../Models/Chat/ChatPreviewMapping.swift | 14 +- FlipcashTests/Chat/AttachMenuTests.swift | 64 ++++ FlipcashTests/Chat/ChatCameraSheetTests.swift | 48 +++ .../Chat/ChatMediaBubbleVectorTests.swift | 64 ++++ FlipcashTests/Chat/ChatMediaCellTests.swift | 112 ++++++ .../Chat/ChatMediaFanOutVectorTests.swift | 76 +++++ .../Chat/ChatMediaStringVectorTests.swift | 123 +++++++ .../Chat/ChatMediaURLResolverTests.swift | 144 ++++++++ .../Chat/ChatMediaUploaderTests.swift | 246 ++++++++++++++ FlipcashTests/Chat/ChatMediaViewerTests.swift | 178 ++++++++++ .../Chat/ChatMessageMappingTests.swift | 26 ++ .../Chat/ChatPhotoStagingTests.swift | 214 ++++++++++++ FlipcashTests/Chat/ChatQuoteBubbleTests.swift | 19 ++ .../Chat/ComposerChipStripTests.swift | 49 +++ FlipcashTests/Chat/ComposerChipTests.swift | 118 +++++++ .../ConversationBarLeadingControlTests.swift | 65 ++++ .../Chat/ConversationMediaSendTests.swift | 321 ++++++++++++++++++ FlipcashTests/Chat/ReactionUITests.swift | 7 + FlipcashTests/ChatPreviewMappingTests.swift | 27 ++ .../TestSupport/MockChatMediaBlobStore.swift | 53 +++ .../TestSupport/MockConversations.swift | 30 ++ .../FlipcashUI/Chat/ChatBubbleView.swift | 13 +- .../Chat/ChatItem+Differentiable.swift | 2 + .../FlipcashUI/Chat/ChatLinkMessageCell.swift | 13 +- .../Chat/ChatMediaBubbleSizing.swift | 35 ++ .../FlipcashUI/Chat/ChatMediaCell.swift | 255 ++++++++++++++ .../Chat/ChatMediaImageSource.swift | 20 ++ .../Chat/ChatMediaURLResolver.swift | 101 ++++++ .../Chat/ChatMediaViewerController.swift | 211 ++++++++++++ .../FlipcashUI/Chat/ChatMessageCell.swift | 15 +- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 71 +++- .../Chat/ChatScreenViewController.swift | 18 + .../FlipcashUI/Chat/ChatViewController.swift | 87 ++++- .../FlipcashUI/Chat/LinkableBubbleView.swift | 5 +- .../Smoke/BlockUnblockSmokeTests.swift | 8 +- .../Screens/ConversationUIScreen.swift | 13 +- .../NotificationTranscriptView.swift | 3 + 62 files changed, 4615 insertions(+), 110 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/AttachMenu.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift create mode 100644 Flipcash/Core/Screens/Conversation/ComposerChip.swift create mode 100644 Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift create mode 100644 FlipcashTests/Chat/AttachMenuTests.swift create mode 100644 FlipcashTests/Chat/ChatCameraSheetTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaCellTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaStringVectorTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaURLResolverTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaUploaderTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaViewerTests.swift create mode 100644 FlipcashTests/Chat/ChatPhotoStagingTests.swift create mode 100644 FlipcashTests/Chat/ComposerChipStripTests.swift create mode 100644 FlipcashTests/Chat/ComposerChipTests.swift create mode 100644 FlipcashTests/Chat/ConversationBarLeadingControlTests.swift create mode 100644 FlipcashTests/Chat/ConversationMediaSendTests.swift create mode 100644 FlipcashTests/TestSupport/MockChatMediaBlobStore.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift diff --git a/.github/workflows/vectors.yml b/.github/workflows/vectors.yml index 5b72532c6..3abb61788 100644 --- a/.github/workflows/vectors.yml +++ b/.github/workflows/vectors.yml @@ -65,7 +65,7 @@ jobs: -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -quiet - # The app target's own vector suites -- chat drafts and link detection -- assert against types + # The app target's own vector suites -- chat drafts, link detection, and chat media -- assert against types # only the app has, so they cannot move into either package above. Heavier than the two # package schemes because it builds the app. # Split so the two failure classes are separable and each stays legible: @@ -82,8 +82,8 @@ jobs: # Two runs of the one built target, because they answer different questions. # - # This is the gate: the two suites that read the synced copies of - # `chat_draft.json` and `link_detection.json`. Those fixtures are canonical and + # This is the gate: the suites that read the synced copies of `chat_draft.json`, + # `link_detection.json`, and `chat_media.json`. Those fixtures are canonical and # code-android-app asserts against the same ones, so a failure here is contract # drift between the platforms -- exactly what this job exists to stop. - name: App vectors @@ -92,6 +92,9 @@ jobs: -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -only-testing:FlipcashTests/ChatDraftVectorTests \ -only-testing:FlipcashTests/LinkCardClassifierTests \ + -only-testing:FlipcashTests/ChatMediaFanOutVectorTests \ + -only-testing:FlipcashTests/ChatMediaBubbleVectorTests \ + -only-testing:FlipcashTests/ChatMediaStringVectorTests \ -resultBundlePath "${{ runner.temp }}/AppVectors.xcresult" # The rest of the target reports but does not gate. It holds timing-sensitive diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 9c475d08a..a9eda1fff 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -315,6 +315,9 @@ final class ConversationController { /// Keeps the words of a send that failed — see ``FailedSendDrafts``. @ObservationIgnored private var failedSends: FailedSendDrafts? + /// The chip behind each photo send not yet confirmed, keyed by its client id: its local image + /// draws the pending bubble, and a failed send uploads or posts again through it. + @ObservationIgnored private var pendingMediaChips: [UUID: ComposerChip] = [:] /// The receive-side analytics concern (cumulative counters + received events), /// owned by its own unit. Exposed so `SessionContainer` can wire its rate lookup. @ObservationIgnored let receipts: ConversationReceiptReporter @@ -1563,7 +1566,7 @@ final class ConversationController { return conversation.type == .group ? label : "You received \(label)" case .media(_, let caption): - let body = "📷 " + (caption ?? "Photo") + let body = ChatMediaStrings.listPreview(caption: caption) if isFromSelf { return "You: \(body)" } guard let senderName else { return body } return "\(senderName): \(body)" @@ -1989,25 +1992,25 @@ final class ConversationController { func retry(clientMessageID: UUID, in conversationID: ConversationID) async { guard let pending = store.pendingMessage(clientMessageID: clientMessageID, in: conversationID), pending.status == .failed else { return } - // Only `.text` is ever sent by this client today -- `deliver(text:)` is the only send path, - // and `send(_:to:)` only ever creates a `.text` pending row -- so this is unreachable in - // practice. It's a `switch` rather than the narrow `guard case .text` it replaces so a future - // pending shape (e.g. an outbox that can hold `.encrypted`) fails loudly via the log below - // instead of silently never retrying, and so `Content.asProto()`'s own crash-free contract - // (no fatalError/force-unwrap for `.encrypted`/`.cash`/`.deleted`) is exercised here too. + // A `switch` rather than a `guard case` so a pending shape with no send path (an outbox that + // could hold `.encrypted`, say) fails loudly via the log below instead of silently never + // retrying, and so `Content.asProto()`'s crash-free contract for `.encrypted`/`.cash`/ + // `.deleted` is exercised here too. switch pending.content { case .text(let text): store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) - case .encrypted, .cash, .deleted, .widget, .media: + case .media(_, let caption): + await retryMedia(clientMessageID: clientMessageID, caption: caption, repliedTo: pending.repliedTo, in: conversationID) + case .encrypted, .cash, .deleted, .widget: if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { logger.error("Cannot retry a send this client has no path to re-send", metadata: [ "conversationID": "\(conversationID)", "error": "\(error)", ]) } - // Nothing to resend over the existing text-only send RPC; leave it `.failed` rather than - // looping forever or crashing. + // Nothing to resend over the send RPCs; leave it `.failed` rather than looping forever + // or crashing. } } @@ -2058,6 +2061,157 @@ final class ConversationController { } } + /// Sends each staged chip as its own message, in chip order, with `caption` under the last and + /// `repliedTo` on the first; returns whether every one was delivered. + /// + /// Every pending bubble appears at once, even for a chip still uploading. Each message is then + /// posted only once its own chip's upload settles, so a later photo never lands before an + /// earlier one. A chip that fails marks only its own bubble `.failed`, and the next chip still + /// gets its turn. + @discardableResult + func sendMedia( + _ chips: [ComposerChip], + caption: String?, + to conversationID: ConversationID, + repliedTo: MessageID? = nil + ) async -> Bool { + let sends = ChatMediaSendPlan.mediaMessages(chips: chips, caption: caption, replyTo: repliedTo).map { message in + (message: message, clientMessageID: insertPendingMedia(message, into: conversationID)) + } + + var deliveredAll = true + for send in sends { + let delivered = await deliverMedia( + clientMessageID: send.clientMessageID, + chip: send.message.chip, + caption: send.message.caption, + repliedTo: send.message.replyTo, + to: conversationID + ) + deliveredAll = deliveredAll && delivered + } + return deliveredAll + } + + /// The local image drawing the pending photo bubble whose transcript id is `messageID`, or `nil` + /// once the send is confirmed or when the row is not a photo this device is sending. + func pendingMediaImage(forMessageID messageID: String) -> UIImage? { + UUID(uuidString: messageID).flatMap { pendingMediaChips[$0]?.image } + } + + private func insertPendingMedia(_ message: ChatMediaSendPlan.MediaMessage, into conversationID: ConversationID) -> UUID { + let clientMessageID = UUID() + let chip = message.chip + // A chip still preparing has no upload size yet; the source's own pixels carry the same + // aspect ratio, which is all the bubble's clamp reads. + let pixels = CGSize(width: chip.image.size.width * chip.image.scale, height: chip.image.size.height * chip.image.scale) + let attachment = MediaAttachment( + blobID: nil, + width: chip.preparedWidth ?? Int(pixels.width.rounded()), + height: chip.preparedHeight ?? Int(pixels.height.rounded()), + blurhash: nil + ) + let pending = ConversationMessage( + id: .unassigned, + senderID: selfUserID, + content: .media([attachment], caption: message.caption), + date: .now, + unreadSeq: 0, + repliedTo: message.replyTo, + status: .sending, + clientMessageID: clientMessageID + ) + let anchor = (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 }?.value ?? 0 + store.insertPending(pending, anchoredTo: anchor, into: conversationID) + receiptSettle.hold(clientMessageID.uuidString) + pendingMediaChips[clientMessageID] = chip + return clientMessageID + } + + /// Re-sends a failed photo: posts again when its blob is finalized, uploads again first when the + /// upload is what failed, and leaves a photo the server refused for good as it is. + private func retryMedia(clientMessageID: UUID, caption: String?, repliedTo: MessageID?, in conversationID: ConversationID) async { + guard let chip = pendingMediaChips[clientMessageID] else { + logger.error("Cannot retry a photo send with no staged image", metadata: [ + "conversationID": "\(conversationID)", + ]) + return + } + switch chip.state { + case .preparing, .uploading, .uploaded: + break + case .failed(.retryable): + chip.retryUpload() + case .failed(.notRetryable): + return + } + store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) + _ = await deliverMedia(clientMessageID: clientMessageID, chip: chip, caption: caption, repliedTo: repliedTo, to: conversationID) + } + + /// Awaits `chip`'s upload, then posts its message; any failure marks the pending row `.failed`. + private func deliverMedia( + clientMessageID: UUID, + chip: ComposerChip, + caption: String?, + repliedTo: MessageID?, + to conversationID: ConversationID + ) async -> Bool { + // Read at await time: a retry replaces the chip's task. + guard let upload = chip.uploadTask else { + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + logger.error("Photo send has no upload to await", metadata: ["conversationID": "\(conversationID)"]) + return false + } + let blobID: BlobID + do { + blobID = try await upload.value + } catch { + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + logger.error("Failed to upload conversation photo", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + ErrorReporting.captureError(error, reason: "Failed to upload conversation photo") + return false + } + + let chatType = conversation(withID: conversationID)?.type + do { + let message = try await messaging.sendMediaMessage( + owner: owner, + conversationID: conversationID, + blobID: blobID, + caption: caption, + repliedTo: repliedTo, + clientMessageID: clientMessageID + ) + var confirmed = message + confirmed.clientMessageID = clientMessageID + let ok = persist(operation: "send-media-message") { try database.upsertConversationMessages([confirmed], conversationID: conversationID) } + if ok { + store.dropPending(clientMessageID: clientMessageID, confirmedAt: message.id, in: conversationID) + pendingMediaChips[clientMessageID] = nil + } else { + scheduleGapCatchUp(conversationID) + } + store.advanceLastActivity(to: message.date, in: conversationID) + refreshFeedPreview(for: conversationID) + persistConversation(conversationID) + Analytics.sentMessage(chatType: chatType) + return true + } catch { + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + logger.error("Failed to send conversation photo", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + ErrorReporting.captureError(error, reason: "Failed to send conversation photo") + Analytics.sentMessage(chatType: chatType, error: error) + return false + } + } + /// Moves the viewer's READ pointer to `messageID`, the newest message someone else sent that has /// been on screen. Never moves it backward. /// diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index de1bf4c1c..669cfc495 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -141,6 +141,11 @@ extension EncryptedChatClient: ConversationMessaging { } } + /// Media has no sealed form, so a photo goes out in plaintext whatever the chat's encryption. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) + } + func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation { try await refetchingOnRefusal(conversationID) { let seal = try await sealForSending(conversationID, owner: owner) diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index e8092e597..cdf13d4d8 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -112,6 +112,8 @@ protocol ConversationMessaging: AnyObject, Sendable { onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void ) async throws -> UInt64 func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage + /// Sends the finalized photo `blobID` as one media message, with `caption` under it. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage /// Replaces a message's text, keeping it a reply to `repliedTo` when set. `expectedEventSequence` is the optimistic-concurrency guard: the /// server applies the edit only if the message still carries that sequence, and reports a /// conflict with the winning state otherwise. diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift new file mode 100644 index 000000000..cdc7c0080 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -0,0 +1,100 @@ +// +// AttachMenu.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import SwiftUI +import FlipcashUI + +/// A row of the composer's attach menu. +enum AttachMenuItem: Equatable { + case cash + case camera + case photos + + /// Returns the menu's rows, top to bottom: Cash wherever Send Cash is offered, then Camera and + /// Photos while the chat accepts media and the composer has room for another photo. + static func items(showsCash: Bool, acceptsMedia: Bool, attachedCount: Int) -> [AttachMenuItem] { + var items: [AttachMenuItem] = [] + if showsCash { + items.append(.cash) + } + if acceptsMedia, attachedCount < ComposerModel.maxAttachments { + items.append(.camera) + items.append(.photos) + } + return items + } + + /// Returns how many photos the picker may add to the `attachedCount` already staged. + static func photosSelectionLimit(attachedCount: Int) -> Int { + max(0, ComposerModel.maxAttachments - attachedCount) + } +} + +/// The composer's leading `+` control: a menu of Cash, Camera, and Photos, in the slot Send Cash +/// takes before the chat exists. +/// +/// Photos opens the system picker, which runs out of process and needs no library permission — +/// the same reason ``GalleryScanButton`` uses it. +struct AttachMenu: View { + + let items: [AttachMenuItem] + /// The most photos one pick may return, so the strip never passes `ComposerModel.maxAttachments`. + let photosSelectionLimit: Int + let onCash: () -> Void + let onCamera: () -> Void + /// Receives the picked photos in the order they were selected. + let onPhotos: ([PhotosPickerItem]) -> Void + + @State private var isPickingPhotos = false + @State private var pickedPhotos: [PhotosPickerItem] = [] + + var body: some View { + Menu { + ForEach(items, id: \.self) { item in + switch item { + case .cash: + Button("Cash", systemImage: "banknote", action: onCash) + case .camera: + Button("Camera", systemImage: "camera", action: onCamera) + case .photos: + // Presented from the modifier below rather than as a `PhotosPicker` row: a + // picker nested in a `Menu` is torn down with the menu before it can present. + Button("Photos", systemImage: "photo.on.rectangle") { isPickingPhotos = true } + } + } + } label: { + Image(systemName: SystemSymbol.plus.rawValue) + .font(.default(size: 20, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + } + // Declared order, top to bottom. The system otherwise reverses a menu that opens upward, + // which is every menu raised from the bottom bar. + .menuOrder(.fixed) + .buttonStyle(.plain) + .glassBackground(cornerRadius: BarMetrics.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .accessibilityLabel("Attach") + .accessibilityIdentifier("attach-menu-button") + .photosPicker( + isPresented: $isPickingPhotos, + selection: $pickedPhotos, + maxSelectionCount: photosSelectionLimit, + selectionBehavior: .ordered, + matching: .images, + photoLibrary: .shared() + ) + .onChange(of: pickedPhotos) { _, picked in + guard !picked.isEmpty else { return } + // Cleared so the next pick starts empty instead of preselecting what was just staged. + pickedPhotos = [] + onPhotos(picked) + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift new file mode 100644 index 000000000..388167b66 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -0,0 +1,198 @@ +// +// ChatCameraSheet.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import AVFoundation +import FlipcashUI + +/// What the chat camera shows for a camera authorization status. +nonisolated enum ChatCameraPhase: Equatable { + + /// Access hasn't been asked for yet; the system prompt is up. + case requestingAccess + + /// Access is granted; the viewfinder runs. + case live + + /// Access is denied or restricted; the panel points to Settings. + case denied + + init(status: AVAuthorizationStatus) { + switch status { + case .notDetermined: + self = .requestingAccess + case .authorized: + self = .live + case .denied, .restricted: + self = .denied + @unknown default: + self = .denied + } + } +} + +/// The attach menu's Camera: a rounded viewfinder with a shutter, a lens flip, and a back chevron, +/// sized by its host to the keyboard's frame. +/// +/// Asks for camera access on first use, since opening the camera is the request. A capture hands +/// the photo to `onCapture`; the host stages it and collapses the camera. +struct ChatCameraSheet: View { + + let onCapture: (UIImage) -> Void + let onCancel: () -> Void + + @State private var authorizer = CameraAuthorizer() + @State private var camera = ChatPhotoCamera() + @State private var isUnavailable = false + + private static let cornerRadius: CGFloat = 24 + + var body: some View { + ZStack { + switch ChatCameraPhase(status: authorizer.status) { + case .requestingAccess: + ProgressView() + .task { _ = try? await authorizer.authorize() } + case .denied: + CameraPromptView( + prompt: .openSettings, + message: "Turn on Camera in Settings to send photos" + ) { + URL.openSettings() + } + case .live: + if isUnavailable { + Text("Camera unavailable") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } else { + viewfinder + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundMain) + .overlay(alignment: .topLeading) { + backButton + } + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) + } + + private var viewfinder: some View { + ChatCameraPreview(session: camera.session) + .overlay(alignment: .bottom) { + controls + } + .task { + do { + try camera.configure() + camera.start() + } catch { + isUnavailable = true + } + } + .onDisappear { + camera.stop() + } + } + + private var controls: some View { + HStack { + // Balances the flip button so the shutter sits on the centre line. + Color.clear + .frame(width: 44, height: 44) + Spacer() + Button { + Task { + if let image = await camera.capture() { + onCapture(image) + } + } + } label: { + Circle() + .strokeBorder(Color.white, lineWidth: 4) + .padding(-2) + .overlay { + Circle() + .fill(Color.white) + .padding(6) + } + .frame(width: 68, height: 68) + } + .buttonStyle(.plain) + .disabled(camera.isCapturing) + .accessibilityLabel("Take photo") + .accessibilityIdentifier("chat-camera-shutter") + Spacer() + Button { + camera.flip() + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 44, height: 44) + .background(Circle().fill(Color.black.opacity(0.35))) + } + .buttonStyle(.plain) + .accessibilityLabel(camera.position == .front ? "Use back camera" : "Use front camera") + .accessibilityIdentifier("chat-camera-flip") + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + } + + private var backButton: some View { + Button(action: onCancel) { + Image(systemName: "chevron.left") + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 44, height: 44) + .background(Circle().fill(Color.black.opacity(0.35))) + } + .buttonStyle(.plain) + .padding(12) + .accessibilityLabel("Back to keyboard") + .accessibilityIdentifier("chat-camera-back") + } +} + +// MARK: - ChatCameraPreview - + +/// The live feed of a capture session, filling its frame. +/// +/// Its own layer rather than FlipcashUI's `CameraViewport`: that view hands out one shared +/// `_CameraPreviewView`, and mounting it here would pull the scanner's preview out of its tab. +private struct ChatCameraPreview: UIViewRepresentable { + + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.backgroundColor = UIColor(Color.backgroundMain) + view.previewLayer.videoGravity = .resizeAspectFill + view.previewLayer.session = session + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) { + if uiView.previewLayer.session !== session { + uiView.previewLayer.session = session + } + } + + final class PreviewView: UIView { + + override class var layerClass: AnyClass { + AVCaptureVideoPreviewLayer.self + } + + var previewLayer: AVCaptureVideoPreviewLayer { + // `layerClass` guarantees the type. + layer as! AVCaptureVideoPreviewLayer + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift new file mode 100644 index 000000000..c99740585 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift @@ -0,0 +1,72 @@ +// +// ChatCameraSlot.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import Observation + +/// Whether the inline camera holds the keyboard's place under the composer, and how tall that +/// place is. +@MainActor +@Observable +final class ChatCameraSlot { + + /// The height the camera takes before a keyboard has risen on this screen. + static let fallbackHeight: CGFloat = 300 + + /// Whether the camera is up in the keyboard's place. + private(set) var isOpen = false + + /// The keyboard's height above the bottom safe area, the last time it rose. + private(set) var height: CGFloat = fallbackHeight + + @ObservationIgnored private var observer: (any NSObjectProtocol)? + + init() { + observer = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: nil + ) { [weak self] notification in + let info = notification.userInfo + let endFrame = info?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + let isLocal = info?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool ?? true + MainActor.assumeIsolated { + guard isLocal, let endFrame, !endFrame.isEmpty else { return } + let safeAreaBottom = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + self?.keyboardWillShow(height: endFrame.height, safeAreaBottom: safeAreaBottom) + } + } + } + + isolated deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Puts the camera up in the keyboard's place. + func open() { + guard !isOpen else { return } + isOpen = true + } + + /// Takes the camera down. + func close() { + guard isOpen else { return } + isOpen = false + } + + /// Records a keyboard `height` tall rising over a window whose bottom safe area is + /// `safeAreaBottom`, and closes the camera, since the keyboard is taking its place back. + func keyboardWillShow(height keyboardHeight: CGFloat, safeAreaBottom: CGFloat) { + let slotHeight = keyboardHeight - safeAreaBottom + if slotHeight > 0, slotHeight != height { + height = slotHeight + } + close() + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index ba11482f5..a7d58db36 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -263,10 +263,18 @@ extension ChatItem { isFromSelf: isFromSelf, senderName: counterpartName )) - case .media(_, let caption): - // A text stand-in until the transcript has a media cell; a redacted photo stays a - // photo row rather than a tombstone. - content = .text("📷 " + (caption ?? "Photo")) + case .media(let attachments, let caption): + // A redacted photo stays a photo row, drawn from its BlurHash, rather than a tombstone. + // A send carries one attachment (see `ChatMediaSendPlan`); the row draws the first. + let attachment = attachments.first + content = .media(ChatMediaContent( + blobID: attachment?.blobID, + width: attachment?.width ?? 0, + height: attachment?.height ?? 0, + blurhash: attachment?.blurhash, + caption: caption, + isRedacted: message.redacted + )) } // The status line rides on the bubble itself (not a separate row, so a send is a clean @@ -437,7 +445,7 @@ extension ChatItem { return ChatQuote( stableID: original.stableID, authorName: authorName, - snippet: ChatQuote.snippet(forText: caption ?? "Photo"), + snippet: ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID), authorID: original.senderID ) diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift b/Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift new file mode 100644 index 000000000..43e507ae0 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift @@ -0,0 +1,54 @@ +// +// ChatMediaSendPlan.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import FlipcashCore + +/// The messages one composer send fans out to, pinned by `chat_media.json`'s `fanOut` section. +/// +/// Generic over the chip so the fixture can name chips with strings while the send path hands in +/// `ComposerChip`s. +enum ChatMediaSendPlan { + + /// One photo's message. + struct MediaMessage { + let chip: Chip + let caption: String? + let replyTo: MessageID? + } + + /// One message a send becomes. + enum Message { + case text(String, replyTo: MessageID?) + case media(MediaMessage) + } + + /// Returns a text message when there are no chips, otherwise one media message per chip. + static func messages(chips: [Chip], text: String?, replyTo: MessageID?) -> [Message] { + guard chips.isEmpty else { + return mediaMessages(chips: chips, caption: text, replyTo: replyTo).map(Message.media) + } + guard let text = nonEmpty(text) else { return [] } + return [.text(text, replyTo: replyTo)] + } + + /// Returns one message per chip in chip order, with `caption` on the last and `replyTo` on the first. + static func mediaMessages(chips: [Chip], caption: String?, replyTo: MessageID?) -> [MediaMessage] { + let caption = nonEmpty(caption) + return chips.enumerated().map { index, chip in + MediaMessage( + chip: chip, + caption: index == chips.count - 1 ? caption : nil, + replyTo: index == 0 ? replyTo : nil + ) + } + } + + private static func nonEmpty(_ text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + return text + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift new file mode 100644 index 000000000..7b3338dec --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift @@ -0,0 +1,219 @@ +// +// ChatMediaUploader.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import ImageIO +import FlipcashCore + +private let logger = Logger(label: "flipcash.chat-media-upload") + +/// The blob calls a chat photo upload makes, in the order it makes them. +/// +/// The owner is bound by the conformer, so the upload never handles keys. +protocol ChatMediaBlobStoring { + + /// Returns the upload constraints in force for the owner. + func uploadPolicy() async throws -> UploadPolicy + + /// Stores `data` and returns its blob, before the server has finalized it. + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID + + /// Returns once the blob is servable, throwing `ErrorBlob.rejected` when it is refused and + /// `ErrorBlob.timedOut` when it is still processing. + func awaitBlobFinalization(blobID: BlobID) async throws +} + +/// Stores chat photos on behalf of the signed-in owner. +struct SessionChatMediaBlobStore: ChatMediaBlobStoring { + + let session: Session + let flipClient: FlipClient + + func uploadPolicy() async throws -> UploadPolicy { + try await flipClient.uploadPolicy(owner: session.ownerKeyPair) + } + + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { + try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair) + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) + } +} + +/// Why a chat photo did not upload. +enum ChatMediaUploadError: Error { + /// The upload policy accepts no JPEG. + case noMatchingConstraint + /// The photo could not be encoded within the policy's size ceiling. + case encodingFailed(ChatMediaEncoder.Error) + /// The server refused the stored bytes. + case rejected(BlobRejectionReason) + /// The upload failed for a reason other than the bytes themselves, after any automatic retries. + case failed(Error) + + /// Whether uploading the same photo again could succeed. + var isRetryable: Bool { + switch self { + case .noMatchingConstraint, .encodingFailed: + false + case .rejected(let reason): + switch reason { + case .moderation: + false + case .unsupportedType, .mismatchedType, .tooLarge, .corrupt, .privacyMetadata, .unknown: + true + } + case .failed: + true + } + } +} + +extension ChatMediaUploadError: ServerError { + var reportingLevel: ErrorReportingLevel { + switch self { + case .noMatchingConstraint, .encodingFailed: + .error + case .rejected: + .info + case .failed(let error): + (error as? ServerError)?.reportingLevel ?? .error + } + } +} + +/// Turns a staged photo into a finalized blob: downscaled to the upload policy's bounds, encoded +/// down the JPEG quality ladder, stored, and awaited until the server has finalized it. +struct ChatMediaUploader { + + let blob: any ChatMediaBlobStoring + + /// The wait before each automatic retry of a store that failed in transit; one entry per retry. + var backoff: [Duration] = [.seconds(1), .seconds(2), .seconds(4)] + + /// Returns the finalized blob for `image`, throwing `ChatMediaUploadError`. + /// + /// `onPrepared` receives the uploaded pixel width and height once, before any bytes are stored. + func upload(_ image: UIImage, onPrepared: (Int, Int) -> Void) async throws -> BlobID { + let policy: UploadPolicy + do { + policy = try await blob.uploadPolicy() + } catch { + try Task.checkCancellation() + throw ChatMediaUploadError.failed(error) + } + + guard let constraint = policy.constraint(for: ChatMediaEncoder.mimeType) else { + logger.warning("Upload policy accepts no chat photo", metadata: ["policyVersion": "\(policy.version)"]) + throw ChatMediaUploadError.noMatchingConstraint + } + guard let cgImage = image.cgImage else { + throw ChatMediaUploadError.encodingFailed(.encodingFailed) + } + + let orientation = CGImagePropertyOrientation(image.imageOrientation) + let isSideways: Bool + switch orientation { + case .left, .leftMirrored, .right, .rightMirrored: + isSideways = true + case .up, .upMirrored, .down, .downMirrored: + isSideways = false + } + + let target = ChatMediaDownscale.target( + sourceWidth: isSideways ? cgImage.height : cgImage.width, + sourceHeight: isSideways ? cgImage.width : cgImage.height, + maxWidth: constraint.image?.maxWidth ?? 0, + maxHeight: constraint.image?.maxHeight ?? 0, + maxPixels: constraint.image?.maxPixels ?? 0 + ) + onPrepared(target.width, target.height) + + let data: Data + do { + data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: constraint.maxSizeBytes) + } catch let error as ChatMediaEncoder.Error { + throw ChatMediaUploadError.encodingFailed(error) + } + + let blobID = try await store(data) + + do { + try await blob.awaitBlobFinalization(blobID: blobID) + } catch ErrorBlob.rejected(let reason) { + throw ChatMediaUploadError.rejected(reason) + } catch { + try Task.checkCancellation() + throw ChatMediaUploadError.failed(error) + } + + return blobID + } + + /// Stores `data`, retrying through `backoff` while the failure is in transit. + private func store(_ data: Data) async throws -> BlobID { + var attempt = 0 + while true { + do { + return try await blob.storeBlob(data, mimeType: ChatMediaEncoder.mimeType) + } catch { + try Task.checkCancellation() + + if let error = error as? ErrorBlob { + switch error { + case .rejected(let reason): + throw ChatMediaUploadError.rejected(reason) + case .network, .uploadFailed, .timedOut, .unknown: + break + case .uploadDenied, .unsupportedType, .tooLarge, .quotaExceeded, .notFound, .notUploaded: + throw ChatMediaUploadError.failed(error) + } + } + + guard attempt < backoff.count else { + throw ChatMediaUploadError.failed(error) + } + + logger.info("Retrying chat photo store", metadata: [ + "attempt": "\(attempt + 1)", + "error": "\(error)", + ]) + try await Task.sleep(for: backoff[attempt]) + attempt += 1 + } + } + } + + /// Encodes off the main actor, since drawing and compressing a full-size photo takes a while. + @concurrent + private nonisolated static func encode( + _ image: CGImage, + orientation: CGImagePropertyOrientation, + target: ChatMediaDownscale.Target, + maxSizeBytes: Int + ) async throws -> Data { + try ChatMediaEncoder().encode(image, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) + } +} + +private extension CGImagePropertyOrientation { + init(_ orientation: UIImage.Orientation) { + switch orientation { + case .up: self = .up + case .upMirrored: self = .upMirrored + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored + @unknown default: self = .up + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift new file mode 100644 index 000000000..e3c273f78 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift @@ -0,0 +1,186 @@ +// +// ChatPhotoCamera.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import Observation +// SAFETY: AVCaptureSession and AVCapturePhotoOutput are documented thread-safe, but AVFoundation +// has not adopted Sendable, so handing the session to the session queue trips compile-time +// warnings. Same exemption `CameraSession` takes. +// FOLLOW-UP: Remove once AVFoundation annotates the capture pipeline as Sendable. +@preconcurrency import AVFoundation + +/// A still-photo camera for the chat composer: one capture session with a photo output, flippable +/// between the back and front lenses. +/// +/// Separate from FlipcashUI's `CameraSession`, which wires only video-data and metadata outputs for +/// scanning and has no still-photo output. +@Observable +final class ChatPhotoCamera { + + /// Why the camera could not be set up. + enum SetupError: Error { + /// The device has no camera at the requested position, as on the simulator. + case deviceUnavailable + /// The session refused the camera's input or the photo output. + case configurationRejected + } + + /// The lens the session is capturing from. + private(set) var position: AVCaptureDevice.Position = .back + + /// Whether a shutter press is still being processed. + private(set) var isCapturing = false + + @ObservationIgnored let session = AVCaptureSession() + + @ObservationIgnored private let output = AVCapturePhotoOutput() + @ObservationIgnored private let sessionQueue = DispatchQueue(label: "com.flipcash.chat-camera.session") + @ObservationIgnored private var input: AVCaptureDeviceInput? + @ObservationIgnored private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + // The output holds its delegate weakly; each capture keeps its own alive until it finishes. + @ObservationIgnored private var pendingCaptures: [Int64: PhotoCaptureDelegate] = [:] + + // Same Swift 6.3 EarlyPerfInliner workaround `CameraSession` carries for @MainActor deinits. + nonisolated deinit {} + + /// Returns the lens a flip moves to from `position`. + nonisolated static func flipped(_ position: AVCaptureDevice.Position) -> AVCaptureDevice.Position { + switch position { + case .back: + .front + case .front, .unspecified: + .back + @unknown default: + .back + } + } + + // MARK: - Session - + + /// Adds the photo output and the current lens to the session; a no-op once configured. + func configure() throws { + guard input == nil else { return } + + session.beginConfiguration() + // An uncommitted session crashes on stopRunning(), so commit on every exit. + defer { session.commitConfiguration() } + + session.sessionPreset = .photo + guard session.canAddOutput(output) else { + throw SetupError.configurationRejected + } + session.addOutput(output) + try attachInput(for: position) + } + + /// Starts the session off the main thread; `startRunning()` blocks until frames flow. + func start() { + let session = session + sessionQueue.async { + guard !session.isRunning else { return } + session.startRunning() + } + } + + /// Stops the session off the main thread. + func stop() { + let session = session + sessionQueue.async { + guard session.isRunning else { return } + session.stopRunning() + } + } + + /// Swaps to the other lens, keeping the current one if the other is missing. + func flip() { + guard input != nil else { return } + let target = Self.flipped(position) + + session.beginConfiguration() + defer { session.commitConfiguration() } + + let previous = input + if let previous { + session.removeInput(previous) + } + do { + try attachInput(for: target) + } catch { + if let previous, session.canAddInput(previous) { + session.addInput(previous) + input = previous + } + } + } + + private func attachInput(for position: AVCaptureDevice.Position) throws { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) else { + throw SetupError.deviceUnavailable + } + guard + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) + else { + throw SetupError.configurationRejected + } + session.addInput(input) + self.input = input + self.position = position + // The app is portrait-locked, so the interface can't say how the phone is held; the + // coordinator reads the device's physical orientation for the photo's EXIF. + rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: nil) + } + + // MARK: - Capture - + + /// Returns the next photo from the running session, or `nil` if the session isn't running or + /// the capture failed. + func capture() async -> UIImage? { + guard session.isRunning, !isCapturing else { return nil } + isCapturing = true + defer { isCapturing = false } + + let settings = AVCapturePhotoSettings() + if let connection = output.connection(with: .video), let rotationCoordinator { + let angle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture + if connection.isVideoRotationAngleSupported(angle) { + connection.videoRotationAngle = angle + } + } + + let id = settings.uniqueID + let data: Data? = await withCheckedContinuation { continuation in + let delegate = PhotoCaptureDelegate { continuation.resume(returning: $0) } + pendingCaptures[id] = delegate + output.capturePhoto(with: settings, delegate: delegate) + } + pendingCaptures[id] = nil + + // `UIImage(data:)` carries the EXIF orientation into `imageOrientation`, which the uploader + // bakes into the pixels before encoding. + return data.flatMap(UIImage.init(data:)) + } +} + +// MARK: - PhotoCaptureDelegate - + +// AVFoundation calls back on its own queue, so the delegate stays off the main actor, as +// `CameraSession`'s delegates do. +// +// SAFETY (`@unchecked Sendable`): the only stored property is an immutable `@Sendable` closure. +private nonisolated final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate, @unchecked Sendable { + + private let completion: @Sendable (Data?) -> Void + + init(completion: @escaping @Sendable (Data?) -> Void) { + self.completion = completion + } + + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + completion(error == nil ? photo.fileDataRepresentation() : nil) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift new file mode 100644 index 000000000..40ab29583 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -0,0 +1,62 @@ +// +// ChatPhotoStaging.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import SwiftUI +import UIKit +import FlipcashCore + +private let logger = Logger(label: "flipcash.chat-photo-staging") + +/// Turns photos picked from the library into composer chips. +enum ChatPhotoStaging { + + /// Loads `items` one at a time in pick order and stages each photo that loads, stopping at the + /// first one the composer refuses for being full; returns the chips staged. + @discardableResult + static func stage( + _ items: [Item], + into composer: ComposerModel, + uploader: ChatMediaUploader, + load: (Item) async -> UIImage? + ) async -> [ComposerChip] { + var staged: [ComposerChip] = [] + for item in items { + guard let image = await load(item) else { continue } + guard let chip = composer.stageChip(image: image, uploader: uploader) else { break } + staged.append(chip) + } + return staged + } + + /// Returns the photo `item` holds, or `nil` when it cannot be loaded or decoded. + static func loadImage(_ item: PhotosPickerItem) async -> UIImage? { + let data: Data + do { + guard let loaded = try await item.loadTransferable(type: Data.self) else { + logger.warning("Picked photo has no data") + return nil + } + data = loaded + } catch { + // Usually an iCloud original that failed to download; nothing a developer can act on. + logger.warning("Picked photo failed to load", metadata: ["error": "\(error)"]) + return nil + } + guard let image = await decode(data) else { + logger.warning("Picked photo failed to decode", metadata: ["bytes": "\(data.count)"]) + return nil + } + return image + } + + /// Decodes off the main actor, since a full-size photo takes a while to parse. + @concurrent + private nonisolated static func decode(_ data: Data) async -> UIImage? { + UIImage(data: data) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index aed97cd68..59774e657 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -5,6 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import PhotosUI import SwiftUI import UIKit import FlipcashCore @@ -117,6 +118,18 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Avatar bytes for the group's members, keyed by user id. Empty in a DM, and empty for a group /// until the pictures download — the rows fall back to a BlurHash, then a monogram. let authorAvatars: [UserID: Data] + /// Whether the chat takes photos; false for an E2EE DM. + var acceptsMedia: Bool = false + /// Receives a photo taken with the inline camera, which the representable opens in the + /// keyboard's place and closes again once the photo is handed over. + var onCameraCapture: (UIImage) -> Void = { _ in } + /// Receives the photos picked from the attach menu, in the order they were selected. + var onPhotosPicked: ([PhotosPickerItem]) -> 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 } + /// Fired when the user taps a photo the viewer may see. The owner opens the full-screen viewer. + var onMediaTap: (ChatMediaViewerRequest) -> Void = { _ in } func makeUIViewController(context: Context) -> ChatScreenViewController { let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator)) @@ -143,6 +156,12 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource + screen.onMediaTap = onMediaTap + context.coordinator.mintMediaURL = mintMediaURL + screen.mediaURLResolver = context.coordinator.mediaURLResolver + screen.pendingMediaImage = { [conversationController] id in + conversationController.pendingMediaImage(forMessageID: id) + } screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite @@ -189,6 +208,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource + screen.onMediaTap = onMediaTap + context.coordinator.mintMediaURL = mintMediaURL screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite @@ -285,7 +306,27 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onGateAddFunds: onGateAddFunds, onGateJoin: onGateJoin, isJoiningChat: isJoiningChat, - mentions: mentions + mentions: mentions, + acceptsMedia: acceptsMedia, + onCamera: { [weak coordinator, camera = barModel.camera] in + coordinator?.screen?.dismissKeyboard() + camera.open() + }, + onCameraCapture: { [weak coordinator, camera = barModel.camera] image in + onCameraCapture(image) + camera.close() + // Back to the keyboard, so the photo can be captioned. + coordinator?.screen?.focusComposer() + }, + onCameraCancel: { [weak coordinator, camera = barModel.camera] in + camera.close() + coordinator?.screen?.focusComposer() + }, + onPhotosPicked: onPhotosPicked, + // Only a member can aim a reply, so the viewer may see what it quotes. + quoteThumbnailURL: { [resolver = coordinator.mediaURLResolver] kind in + await resolver.thumbnailURL(for: kind, canReact: true) + } ) .environment(conversationController) .modifier( @@ -307,6 +348,15 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var barHost: UIHostingController? weak var screen: ChatScreenViewController? var lastMessageID: String? + /// The latest ``ChatScreenRepresentable/mintMediaURL``, read by the resolver. + var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } + /// The one resolver the transcript and the composer's reply strip share, so a photo is minted + /// once however many places draw it. Reads ``mintMediaURL`` at fetch time, so a chat created + /// after this screen opened mints against its real id. + lazy var mediaURLResolver = ChatMediaURLResolver { [weak self] blobID in + guard let self else { return nil } + return try await self.mintMediaURL(blobID) + } } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerChip.swift b/Flipcash/Core/Screens/Conversation/ComposerChip.swift new file mode 100644 index 000000000..7deb02c7c --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -0,0 +1,90 @@ +// +// ComposerChip.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import Observation +import FlipcashCore + +/// Whether a failed chip can be uploaded again, or the server refused its bytes for good. +enum ChatMediaChipFailure: Equatable { + case notRetryable + case retryable +} + +/// A photo staged in the composer, and how far its upload has got. +/// +/// A reference type so the send path can hold the chip it was handed and await its upload later; +/// a chip lives only as long as the composer, never in the persisted draft. +@MainActor +@Observable +final class ComposerChip: Identifiable { + + enum State: Equatable { + case preparing + case uploading + case uploaded(BlobID) + case failed(ChatMediaChipFailure) + } + + let id = UUID() + let image: UIImage + var state: State = .preparing + + /// Pixel width of the image as it will be uploaded, known before the upload starts. + var preparedWidth: Int? + + /// Pixel height of the image as it will be uploaded, known before the upload starts. + var preparedHeight: Int? + + /// The upload in flight or finished for this chip, which the send path awaits rather than + /// starting its own. + @ObservationIgnored var uploadTask: Task? + + /// The uploader the last attempt went through, kept so a failed send can upload again after the + /// composer has let go of the chip. + @ObservationIgnored private var uploader: ChatMediaUploader? + + init(image: UIImage) { + self.image = image + } + + /// Uploads the image through `uploader`, replacing any earlier attempt; calling it again after + /// a retryable failure is how the chip is retried. + func startUpload(using uploader: ChatMediaUploader) { + self.uploader = uploader + uploadTask?.cancel() + state = .preparing + + uploadTask = Task { + do { + let blobID = try await uploader.upload(image) { width, height in + preparedWidth = width + preparedHeight = height + state = .uploading + } + if !Task.isCancelled { + state = .uploaded(blobID) + } + return blobID + } catch let error as ChatMediaUploadError { + if !Task.isCancelled { + state = .failed(error.isRetryable ? .retryable : .notRetryable) + } + throw error + } + } + } + + /// Uploads again through the uploader the last attempt used, returning `false` when no upload + /// was ever started. + @discardableResult + func retryUpload() -> Bool { + guard let uploader else { return false } + startUpload(using: uploader) + return true + } +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift new file mode 100644 index 000000000..476bce9b4 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -0,0 +1,152 @@ +// +// ComposerChipStrip.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI + +/// What a staged chip draws over its thumbnail. +enum ComposerChipBadge: Equatable { + /// The photo is still being prepared or uploaded. + case progress + /// The upload failed and can be tried again. + case retry + /// The server refused the photo; the only way forward is to remove it. + case error + /// The photo is uploaded, so the thumbnail stands alone. + case none + + /// Returns the badge for a chip in `state`. + init(_ state: ComposerChip.State) { + switch state { + case .preparing, .uploading: self = .progress + case .uploaded: self = .none + case .failed(.retryable): self = .retry + case .failed(.notRetryable): self = .error + } + } +} + +/// The photos staged for the next send, as a row of thumbnails above the message field. +/// +/// Part of the bar's own content rather than a reveal like the reply strip: its arrival changes the +/// bar's height the way a draft wrapping to a second line does, which the screen takes in one frame. +struct ComposerChipStrip: View { + + /// Side of a chip's square thumbnail, per the spec's 56pt chips. + static let chipSize: CGFloat = 56 + + let chips: [ComposerChip] + let onRemove: (ComposerChip.ID) -> Void + let onRetry: (ComposerChip) -> Void + + /// Returns whether the strip is drawn: while chips are staged, and not during an edit, which + /// takes the bar for itself and hands the chips back when it ends. + static func isShown(chipCount: Int, isEditing: Bool) -> Bool { + chipCount > 0 && !isEditing + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(chips) { chip in + ComposerChipView( + chip: chip, + onRemove: { onRemove(chip.id) }, + onRetry: { onRetry(chip) } + ) + } + } + } + .scrollIndicators(.hidden) + .frame(height: Self.chipSize) + .accessibilityIdentifier("composer-chip-strip") + } +} + +/// One staged photo: its thumbnail, a remove button, and the upload's badge. +private struct ComposerChipView: View { + + let chip: ComposerChip + let onRemove: () -> Void + let onRetry: () -> Void + + private static let cornerRadius: CGFloat = 10 + + /// A thumbnail sized for the chip, decoded off the main thread. The staged image is the full + /// camera or library photo, which is far too large to redraw at 56pt on every pass. + @State private var thumbnail: UIImage? + + var body: some View { + let size = ComposerChipStrip.chipSize + ZStack { + Color.backgroundSecondary + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + } + badge + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) + .overlay(alignment: .topTrailing) { + Button(action: onRemove) { + Image(systemName: SystemSymbol.closeCircle.rawValue) + .font(.default(size: 18, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.black.opacity(0.55)) + .padding(3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove photo") + .accessibilityIdentifier("composer-chip-remove") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("composer-chip") + .task(id: chip.id) { + let scale = UITraitCollection.current.displayScale + let pixels = CGSize(width: size * scale, height: size * scale) + thumbnail = await chip.image.byPreparingThumbnail(ofSize: pixels) ?? chip.image + } + } + + @ViewBuilder + private var badge: some View { + switch ComposerChipBadge(chip.state) { + case .progress: + ZStack { + Color.black.opacity(0.35) + ProgressView() + .tint(.white) + } + .accessibilityLabel("Uploading photo") + case .retry: + Button(action: onRetry) { + ZStack { + Color.black.opacity(0.45) + Image(systemName: "arrow.clockwise") + .font(.default(size: 18, weight: .semibold)) + .foregroundStyle(Color.white) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Retry photo upload") + .accessibilityIdentifier("composer-chip-retry") + case .error: + ZStack { + Color.black.opacity(0.45) + Image(systemName: "exclamationmark.triangle.fill") + .font(.default(size: 18, weight: .semibold)) + .foregroundStyle(Color.textError) + } + .accessibilityLabel("Photo can't be sent") + case .none: + EmptyView() + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 1f75f8e53..108d94bbc 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -5,7 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // -import Foundation +import UIKit import Observation import SwiftUI import FlipcashCore @@ -40,6 +40,9 @@ final class ComposerModel { case replying(to: ReplyTarget) } + /// The most photos one send can carry, matching `chat_media.json`'s `maxAttachments`. + static let maxAttachments = 10 + private(set) var mode: Mode = .new var draft = "" /// The field's cursor or selection, while it has focus. @@ -48,6 +51,9 @@ final class ComposerModel { /// taken from. var selection: TextSelection? + /// Photos staged for the next send, in the order they were added. + private(set) var chips: [ComposerChip] = [] + /// The unsent new-message draft, held while an edit occupies the field. @ObservationIgnored private var stashedDraft = "" /// The text the message had when the edit began, so an unchanged edit can be refused. @@ -66,7 +72,47 @@ final class ComposerModel { } } - var canSubmit: Bool { submission != nil } + /// Whether the send button can fire: text or at least one chip, and no chip that failed. Chips + /// still uploading do not hold the send back, and an edit is text only. + var canSubmit: Bool { + switch mode { + case .new, .replying: + !hasFailedChip && (submission != nil || !chips.isEmpty) + case .editing: + submission != nil + } + } + + /// What the send button posts for a new message or a reply. + enum Outgoing { + case text(String) + /// The staged photos in order, with the trimmed text as the caption. + case media([ComposerChip], caption: String?) + } + + /// What the send button would post now, or `nil` when it cannot fire or the field holds an edit. + var outgoing: Outgoing? { + switch mode { + case .editing: + return nil + case .new, .replying: + guard canSubmit else { return nil } + if chips.isEmpty { + return submission.map(Outgoing.text) + } + return .media(chips, caption: submission) + } + } + + /// Whether any staged chip failed to upload, which holds the send until it is retried or removed. + var hasFailedChip: Bool { + chips.contains { chip in + switch chip.state { + case .failed: true + case .preparing, .uploading, .uploaded: false + } + } + } /// The transcript row an edit is open on, if any. The chat screen keys its edit backdrop off /// this, so reading it is what ties the backdrop's lifetime to the composer's mode. @@ -163,7 +209,26 @@ final class ComposerModel { mode = .new } - /// Empties the field after a successful send. + /// Stages `image` as a new chip and starts its upload through `uploader`, returning the chip, or + /// returns `nil` when the composer already holds `maxAttachments` chips. + @discardableResult + func stageChip(image: UIImage, uploader: ChatMediaUploader) -> ComposerChip? { + guard chips.count < Self.maxAttachments else { return nil } + let chip = ComposerChip(image: image) + chips.append(chip) + chip.startUpload(using: uploader) + return chip + } + + /// Drops the chip with `id` and cancels its upload. + func removeChip(_ id: ComposerChip.ID) { + guard let index = chips.firstIndex(where: { $0.id == id }) else { return } + chips[index].uploadTask?.cancel() + chips.remove(at: index) + } + + /// Empties the field and the chip strip once a send has taken them. The chips' uploads keep + /// running, since the send awaits them. func clear() { // Only when it actually changes. `@Observable` fires on assignment without comparing, and // the chat screen's body reads `isEditing` and `editingStableID` — both derived from this — @@ -176,6 +241,9 @@ final class ComposerModel { stashedDraft = "" draft = "" selection = nil + if !chips.isEmpty { + chips = [] + } } /// The `@word` at the cursor that the mention picker searches for, or `nil` when it is closed. diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index c84f2697a..f4766486e 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -8,6 +8,7 @@ import SwiftUI import FlipcashCore import FlipcashUI +import Kingfisher /// The quoted original above the composer while a reply is being written: a rule in the author's own /// colour, the author's name over one or two lines of what they said, and the way out on the @@ -22,9 +23,13 @@ import FlipcashUI struct ComposerReplyStrip: View { let target: ComposerModel.ReplyTarget + /// Where a quoted photo's thumbnail loads from; nil for anything else, or a photo with none to fetch. + var thumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } let onDismiss: () -> Void @Environment(\.barCardCollapsed) private var collapsed + @State private var resolvedThumbnail: URL? + /// Wider than the 4pt a blockquote rule usually takes, because the quote's corner radius is the /// bar's 14: the leading edge is straight for only `contentHeight - 14 * 2` of its run, and the /// corners taper the rest away. Thickness can't lengthen that straight core — it only gives the @@ -43,6 +48,9 @@ struct ComposerReplyStrip: View { /// rather than as a second element the line has to make room for. private static let flagDiameter: CGFloat = 16 + /// The quote's two text lines' height, so the photo sits beside them without growing the strip. + private static let thumbnailSide: CGFloat = 38 + var body: some View { // One colour for the rule and the name, so the rule keeps the name's contrast on the surface. let name = ComplementaryPalette.nameColor(for: target.authorID) @@ -63,6 +71,8 @@ struct ComposerReplyStrip: View { .accessibilityLabel("Replying to \(target.authorName): \(spokenSnippet)") .accessibilityIdentifier("composer-reply-quote") + thumbnail + // A disc rather than a bare ✕, because the ground behind it is glass sampling the // transcript: a hairline glyph's contrast changed with whatever message scrolled past. // The fill gives it its own ground and takes that variable out. @@ -145,6 +155,31 @@ struct ComposerReplyStrip: View { } } + /// A quoted photo's thumbnail; nothing for any other quote, or a redacted photo. + @ViewBuilder + private var thumbnail: some View { + switch target.kind { + case .media(let thumbnailBlobID?): + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.08)) + .overlay { + if let resolvedThumbnail { + KFImage(source: .network(ChatMediaImageSource.resource(blobID: thumbnailBlobID, url: resolvedThumbnail))) + .resizable() + .scaledToFill() + } + } + .frame(width: Self.thumbnailSide, height: Self.thumbnailSide) + .clipShape(.rect(cornerRadius: 6)) + .accessibilityHidden(true) + .task(id: thumbnailBlobID) { + resolvedThumbnail = await thumbnailURL(target.kind) + } + case .media(nil), .text, .cash, .unavailable: + EmptyView() + } + } + /// What VoiceOver reads for the quote — the mint's name is part of a payment's identity, and it /// is drawn as a separate run that the combined element would otherwise drop. private var spokenSnippet: String { diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 4d5c6a6ad..9d8725ccf 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -5,6 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import PhotosUI import SwiftUI import FlipcashCore import FlipcashUI @@ -14,6 +15,8 @@ import FlipcashUI /// `ComposerModel`, which also knows whether it is a new message or an edit. @MainActor @Observable final class ConversationBarModel { var isComposing = false + /// The inline camera, which takes the keyboard's place under the composer. + let camera = ChatCameraSlot() } /// Single spring driving the whole bar: the button morph, the composer's @@ -47,7 +50,36 @@ enum BarMetrics { static let compactInset: CGFloat = 32 } -/// The unified bottom bar: Send Cash (morphing) beside the message field. +/// What stands in the bar's leading slot, beside the message field. +enum ConversationBarLeadingControl: Equatable { + /// The way out of an edit. + case cancelEdit + /// The full-width Send Cash call to action, alone in the bar before the chat exists. + case sendCash + /// The `+` menu with these rows. + case attach([AttachMenuItem]) + /// Nothing: the chat takes neither cash nor media from this user. + case none + + /// Returns the control for the bar's state. An edit wins; before the chat exists the only + /// control is the Send Cash call to action; after, it is the attach menu, when it has a row. + init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool, acceptsMedia: Bool, attachedCount: Int) { + if isEditing { + self = .cancelEdit + } else if !chatExists { + self = showsSendCash ? .sendCash : .none + } else { + let items = AttachMenuItem.items( + showsCash: showsSendCash, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) + self = items.isEmpty ? .none : .attach(items) + } + } +} + +/// The unified bottom bar: the attach menu beside the message field. /// A standard-size filled Send Cash alone until the chat exists server-side. struct ConversationBottomBar: View { @@ -88,6 +120,18 @@ struct ConversationBottomBar: View { /// The composer row's measured height, reported as part of what the mention list's room is /// measured without. @State private var composerRowHeight: CGFloat = 0 + /// Whether the chat takes photos; false for an E2EE DM, whose encryption does not cover media. + var acceptsMedia: Bool = false + /// Opens the camera from the attach menu. + var onCamera: () -> Void = {} + /// Receives a photo the inline camera took. + var onCameraCapture: (UIImage) -> Void = { _ in } + /// Fired by the inline camera's back chevron. + var onCameraCancel: () -> Void = {} + /// Receives the photos picked from the attach menu, in the order they were selected. + var onPhotosPicked: ([PhotosPickerItem]) -> Void = { _ in } + /// Where the reply strip's quoted photo loads its thumbnail from. + var quoteThumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } /// The curve the bar narrows and widens on as the keyboard goes and comes. private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) @@ -130,33 +174,58 @@ struct ConversationBottomBar: View { // gained or lost. Nothing animates that travel — the bar's springs key on `chatExists` and // `isEditing`, neither of which moves during a send — so it snapped while the bar's height // sprang underneath it. - let content = HStack(alignment: .bottom, spacing: 10) { - // An edit takes over the bar: the leading control becomes the way out of it and Send - // Cash steps aside until it resolves, the way WhatsApp hides its accessory controls. - if composer.isEditing { - CancelEditButton { composer.endEditing() } - } else if showsSendCash { - SendCashMorphButton( - symbol: symbol, - // Minimized by a reply as well as by focus. Starting a reply from the context - // menu raises the keyboard, and focus — and so `isComposing` — arrives a - // transaction later than the reply target, on its own bouncy spring: the button - // collapsed after the bar had already grown, jolting the field beside it. - // Reading the target directly puts the morph in the reply's own transaction, so - // the two move together and the later focus change finds nothing left to do. - composing: model.isComposing || composer.replyTarget != nil, - standalone: !chatExists, - // Every chat sits minimized beside its composer, but before - // the first tip there is no composer to sit beside: the - // design draws the full-width "Start Chatting" CTA - // (node 10074:18891). - alwaysMinimized: chatExists, - expandedTitle: isTipDm ? startChattingTitle : nil, - action: onSendCash + let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { + // Below the reply strip and above the field, as the spec orders them. + if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { + ComposerChipStrip( + chips: composer.chips, + onRemove: { composer.removeChip($0) }, + onRetry: { $0.retryUpload() } ) } - if chatExists { - ConversationComposer(conversationID: conversationID, model: model, composer: composer) + HStack(alignment: .bottom, spacing: 10) { + // An edit takes over the bar: the leading control becomes the way out of it and the + // attach menu steps aside until it resolves, the way WhatsApp hides its accessory controls. + switch leadingControl { + case .cancelEdit: + CancelEditButton { composer.endEditing() } + case .sendCash: + SendCashMorphButton( + symbol: symbol, + // Minimized by a reply as well as by focus. Starting a reply from the context + // menu raises the keyboard, and focus — and so `isComposing` — arrives a + // transaction later than the reply target, on its own bouncy spring: the button + // collapsed after the bar had already grown, jolting the field beside it. + // Reading the target directly puts the morph in the reply's own transaction, so + // the two move together and the later focus change finds nothing left to do. + composing: model.isComposing || composer.replyTarget != nil, + // Before the first tip there is no composer to sit beside: the design draws + // the full-width "Start Chatting" CTA (node 10074:18891). + standalone: true, + expandedTitle: isTipDm ? startChattingTitle : nil, + action: onSendCash + ) + case .attach(let items): + AttachMenu( + items: items, + photosSelectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), + onCash: onSendCash, + onCamera: onCamera, + onPhotos: onPhotosPicked + ) + case .none: + EmptyView() + } + if chatExists { + ConversationComposer(conversationID: conversationID, model: model, composer: composer) + .transition(.opacity) + } + } + // In the keyboard's place: the screen lowers the keyboard as this opens, and the bar + // grows by what the keyboard held, so the field stays where it was. + if model.camera.isOpen { + ChatCameraSheet(onCapture: onCameraCapture, onCancel: onCameraCancel) + .frame(height: max(0, model.camera.height - Self.rowSpacing)) .transition(.opacity) } } @@ -171,6 +240,7 @@ struct ConversationBottomBar: View { .padding(.bottom, BarMetrics.contentPadding) .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) + .animation(barMorphSpring, value: model.camera.isOpen) // No shared GlassEffectContainer: the composer's glass is a background // layer behind an editable text field, and a container composites its @@ -203,7 +273,7 @@ struct ConversationBottomBar: View { // already drives this state in both directions, and wrapping the dismissal in a second // transaction gave the exit a curve the entry never had. AccessoryReveal(kind: .reply, item: barReply, collapsesInPlace: mentionCandidates != nil) { target in - ComposerReplyStrip(target: target) { composer.endReplying() } + ComposerReplyStrip(target: target, thumbnailURL: quoteThumbnailURL) { composer.endReplying() } } // The quote narrows with the row below it, so the two keep one margin. .padding(.horizontal, compactExtraInset) @@ -258,6 +328,20 @@ struct ConversationBottomBar: View { return listedCandidates } + /// The gap between the bar's rows. The camera's height gives it back, so the bar grows by + /// exactly the keyboard's height. + private static let rowSpacing: CGFloat = 8 + + private var leadingControl: ConversationBarLeadingControl { + ConversationBarLeadingControl( + isEditing: composer.isEditing, + chatExists: chatExists, + showsSendCash: showsSendCash, + acceptsMedia: acceptsMedia, + attachedCount: composer.chips.count + ) + } + /// The tip CTA's title. Names the amount that opens the chat when a floor /// has resolved; the fee is what the recipient charges for the /// conversation, so stating it is the whole point of the button. @@ -724,28 +808,32 @@ struct ConversationComposer: View { // so the composer stays ready immediately. Emptying the field up front makes a double-tap a // no-op, because there is then nothing to submit. switch composer.mode { - case .new: - guard let text = composer.submission else { return } + case .new, .replying: + guard let outgoing = composer.outgoing else { return } + let repliedTo = composer.replyTarget?.messageID // Snapshotted before the field is emptied: a send that fails has no persisted record on - // this platform, so this is the only copy of the words left to put back. + // this platform, so this is the only copy of the words left to put back. A reply's strip + // travels with the text, since restoring the words alone would downgrade it to a loose + // message. let draft = composer.persistableDraft composer.clear() isFocused = true - Task { await conversationController.send(text, to: conversationID, restoringOnFailure: draft) } - case .replying(let target): - guard let text = composer.submission else { return } - // The strip travels with the text. Restoring the words alone would downgrade a reply to - // a loose message, which is the wrong-context send this is here to prevent. - let draft = composer.persistableDraft - composer.clear() - isFocused = true - Task { - await conversationController.send( - text, - to: conversationID, - repliedTo: target.messageID, - restoringOnFailure: draft - ) + switch outgoing { + case .text(let text): + Task { + await conversationController.send( + text, + to: conversationID, + repliedTo: repliedTo, + restoringOnFailure: draft + ) + } + case .media(let chips, let caption): + // A photo that fails stays in the transcript to be retried, so nothing goes back + // into the field. + Task { + await conversationController.sendMedia(chips, caption: caption, to: conversationID, repliedTo: repliedTo) + } } case .editing(let messageID, _): // Confirming an edit that changed nothing leaves edit mode rather than round-tripping @@ -800,7 +888,6 @@ struct SendCashMorphButton: View { /// the bar at the standard filled-button size instead of field-sized. let standalone: Bool /// Forces the compact symbol-only presentation regardless of composing. - /// The bar sets it once a chat exists; only the pre-chat CTA expands. var alwaysMinimized: Bool = false /// Replaces "Send " while expanded. A tip chat names the tip /// instead of the currency, because the amount is chosen on the next screen. diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index c206635cc..c693a4b17 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -5,6 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import PhotosUI import SwiftUI import UIKit import Combine @@ -536,10 +537,64 @@ struct ConversationScreen: View { onGateJoin: joinChat, isJoiningChat: isJoiningChat, mentions: mentions, - authorAvatars: authorAvatars + authorAvatars: authorAvatars, + acceptsMedia: acceptsMedia, + onCameraCapture: stageCapturedPhoto, + onPhotosPicked: stagePickedPhotos, + mintMediaURL: mintMediaURL, + onMediaTap: openMediaViewer ) } + /// Whether the chat takes photos: any chat this device has a record of, except an E2EE DM, whose + /// encryption does not cover media. False until the record loads, so the menu never offers a + /// photo the chat may refuse. + private var acceptsMedia: Bool { + guard let conversationID, + let conversation = conversationController.conversation(withID: conversationID) else { return false } + return !conversation.useE2Ee + } + + /// Uploads chat photos on behalf of the signed-in owner. + private var mediaUploader: ChatMediaUploader { + ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) + } + + /// Stages photos picked from the library, in the order they were picked. + private func stagePickedPhotos(_ items: [PhotosPickerItem]) { + let uploader = mediaUploader + Task { + await ChatPhotoStaging.stage(items, into: composer, uploader: uploader, load: ChatPhotoStaging.loadImage) + } + } + + /// Stages a photo taken with the inline camera. + private func stageCapturedPhoto(_ image: UIImage) { + composer.stageChip(image: image, uploader: mediaUploader) + } + + /// Opens a tapped photo full screen, zooming out of its row, with share through the system sheet. + /// The transcript only hands over a request for a photo the viewer may see, so nothing here can + /// open or download a BlurHash-only one. + private func openMediaViewer(_ request: ChatMediaViewerRequest) { + guard var presenter = UIApplication.shared.currentKeyWindow?.rootViewController else { return } + while let presented = presenter.presentedViewController { + presenter = presented + } + let viewer = ChatMediaViewerController(request: request) { image in + ShareSheet.present(activityItems: [image]) { _ in } + } + presenter.present(viewer, animated: true) + } + + /// Mints a download URL for a photo in this chat, read through the chat's access context. + private var mintMediaURL: (BlobID) async throws -> URL? { + { [flipClient = container.flipClient, owner = session.ownerKeyPair, conversationID] blobID in + guard let conversationID else { return nil } + return try await flipClient.blobDownloadURL(blobID: blobID, owner: owner, accessContext: .chatMessage(conversationID)) + } + } + var body: some View { // The UIKit transcript hosts the bar internally and owns all keyboard handling, so there's // no SwiftUI `.safeAreaInset` bar here. @@ -1098,7 +1153,7 @@ struct ConversationScreen: View { ) case .media(let attachments, let caption): ( - ChatQuote.snippet(forText: caption ?? "Photo"), + ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), .media(thumbnailBlobID: message.redacted ? nil : attachments.first?.blobID) ) case .deleted: diff --git a/Flipcash/Core/Screens/Main/CameraPromptView.swift b/Flipcash/Core/Screens/Main/CameraPromptView.swift index 45a14e3a3..0a013823a 100644 --- a/Flipcash/Core/Screens/Main/CameraPromptView.swift +++ b/Flipcash/Core/Screens/Main/CameraPromptView.swift @@ -80,11 +80,13 @@ struct CameraPromptView: View { let prompt: CameraPrompt /// Whether shown inside the v2 tab-bar UI (affects the permission copy). var embedded: Bool = false + /// Replaces the scanner wording, for a camera used for something other than scanning. + var message: String? = nil let action: () -> Void var body: some View { VStack(spacing: 40) { - Text(prompt.message(embedded: embedded)) + Text(message ?? prompt.message(embedded: embedded)) .frame(maxWidth: 260) .multilineTextAlignment(.center) diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 188d53a12..1167e363e 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -60,7 +60,7 @@ nonisolated struct ChatSpotlightItem { case .text(let text): text case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" case .widget(.shareProfile): "Shared a profile" - case .media(_, let caption): "📷 " + (caption ?? "Photo") + case .media(_, let caption): ChatMediaStrings.listPreview(caption: caption) case .deleted, .encrypted, .widget(.unrecognized), nil: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 388bdfbd8..f4bf7094f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -191,6 +191,14 @@ extension FlipClient { } } + /// Sends the finalized photo `blobID` as one media message, with `caption` under it. + @discardableResult + public func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + try await withCheckedThrowingContinuation { c in + chatMessagingService.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) { c.resume(with: $0) } + } + } + public func editMessage( owner: KeyPair, conversationID: ConversationID, diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 9c50fb9a9..da11e901a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -168,10 +168,39 @@ final class ChatMessagingService: Sendable { completion(.failure(.encryptionFailed)) return } + send(content, owner: owner, conversationID: conversationID, seal: seal, clientMessageID: clientMessageID, completion: completion) + } + + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { + let media = Flipcash_Messaging_V1_Content.with { + $0.media = .with { + $0.items = [.with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + }] + }] + if let caption { + $0.caption = .with { $0.text = caption } + } + } + } + // A reply wraps the media one level deeper on the wire. The domain model keeps it flat — + // see `ConversationMessage.init?(_:)`, which unwraps it back. + let content = repliedTo.map { repliedTo in + Flipcash_Messaging_V1_Content.with { + $0.reply = .with { + $0.repliedMessageID = repliedTo.proto + $0.content = [media] + } + } + } ?? media + send(content, owner: owner, conversationID: conversationID, seal: nil, clientMessageID: clientMessageID, completion: completion) + } + + private func send(_ content: Flipcash_Messaging_V1_Content, owner: KeyPair, conversationID: ConversationID, seal: ChatSeal?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Messaging_V1_SendMessageRequest.with { $0.chatID = conversationID.proto - // A reply wraps the text one level deeper on the wire. The domain model keeps it flat — - // see `ConversationMessage.init?(_:)`, which unwraps it back. $0.content = [content] $0.clientMessageID = .with { $0.value = clientMessageID.data } $0.auth = owner.authFor(message: $0) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift new file mode 100644 index 000000000..b2909853a --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift @@ -0,0 +1,29 @@ +// +// ChatMediaStrings.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The words a photo message is summarised in wherever it cannot be drawn: a reply's quote, the chat +/// list, Spotlight, and notifications. The copy is the fixture's `strings` section, shared with Android. +public enum ChatMediaStrings { + + /// A quote's snippet for a photo: its caption, or "Photo" when it has none. + public static func quoteSnippet(caption: String?) -> String { + nonEmpty(caption) ?? "Photo" + } + + /// A one-line preview of a photo: the camera glyph ahead of its caption, or ahead of "Photo". + public static func listPreview(caption: String?) -> String { + "📷 " + quoteSnippet(caption: caption) + } + + // The transcript draws no caption bubble for an empty caption, so neither does a preview. + private static func nonEmpty(_ caption: String?) -> String? { + guard let caption, !caption.isEmpty else { return nil } + return caption + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index ee4e2ba59..deb0d19a5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -32,6 +32,8 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// A shared profile, drawn as a card with a Share button. The card carries only the /// handle's link; the name and picture are looked up by the view, as a link card's are. case shareProfile(LinkCard.User) + /// A photo, drawn from its blob once a download URL is resolved and from its BlurHash until then. + case media(ChatMediaContent) } /// The line under an unavailable message's bubble, chosen by why it can't be shown. @@ -147,7 +149,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { guard isEmojiOnly, quote == nil, linkPreview == nil else { return false } switch content { case .text: return true - case .cash, .deleted, .unavailable, .shareProfile: return false + case .cash, .deleted, .unavailable, .shareProfile, .media: return false } } @@ -166,10 +168,15 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// left to add a new one to) and for the user's own message still in flight — the server has /// nothing to record a reaction against until the send lands. A row with no context menu at all /// never reaches this check, since the menu itself is what hosts the strip. + /// A redacted photo keeps its reactions hidden, so it offers no strip to add one either. public var offersReactionStrip: Bool { switch content { - case .deleted, .unavailable: return false - case .text, .cash, .shareProfile: return !isUnsent + case .deleted, .unavailable: + return false + case .media(let media) where media.isRedacted: + return false + case .text, .cash, .shareProfile, .media: + return !isUnsent } } @@ -324,6 +331,34 @@ public struct ChatAuthor: Hashable, Sendable, Codable { } } +/// A photo row's display data: what the bubble needs to size itself and draw a placeholder before +/// any bytes arrive. The download URL and a pending row's local image stay outside this value — a +/// URL is signed and minted per fetch, and an image is not `Codable` — and reach the cell at +/// configure time instead. +public struct ChatMediaContent: Hashable, Sendable, Codable { + /// The photo's blob, or nil for a pending row whose upload has not produced one yet. + public let blobID: BlobID? + /// Pixel width of the photo, or 0 when the server carried none. + public let width: Int + /// Pixel height of the photo, or 0 when the server carried none. + public let height: Int + /// The photo's BlurHash, drawn until the image loads. + public let blurhash: String? + /// The text sent with the photo, drawn as its own bubble under the image. + public let caption: String? + /// Whether the server withheld the photo's download URL; the bubble then only ever draws its BlurHash. + public let isRedacted: Bool + + public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?, caption: String?, isRedacted: Bool) { + self.blobID = blobID + self.width = width + self.height = height + self.blurhash = blurhash + self.caption = caption + self.isRedacted = isRedacted + } +} + /// A cash payment row's display data — already formatted, so the cell renders strings + images. public struct ChatCashContent: Hashable, Sendable, Codable { /// The amount in the user's currency, formatted for display (e.g. "$5.00"). diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index e27dedd18..807d00b82 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -91,9 +91,17 @@ extension ChatItem { content = .shareProfile(LinkCard.User(profileOf: share.username)) case .widget(.unrecognized): content = .unavailable(.updateApp) - case .media(_, let caption): - // A text stand-in until the preview has a media row to draw. - content = .text("📷 " + (caption ?? "Photo")) + case .media(let attachments, let caption): + // The same row the transcript maps to; the extension decides how to draw it. + let attachment = attachments.first + content = .media(ChatMediaContent( + blobID: attachment?.blobID, + width: attachment?.width ?? 0, + height: attachment?.height ?? 0, + blurhash: attachment?.blurhash, + caption: caption, + isRedacted: message.redacted + )) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } diff --git a/FlipcashTests/Chat/AttachMenuTests.swift b/FlipcashTests/Chat/AttachMenuTests.swift new file mode 100644 index 000000000..eac7dc910 --- /dev/null +++ b/FlipcashTests/Chat/AttachMenuTests.swift @@ -0,0 +1,64 @@ +// +// AttachMenuTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +@testable import Flipcash + +@MainActor +@Suite("Attach menu") +struct AttachMenuTests { + + @Test("A chat that takes cash and media lists Cash, Camera, Photos, top to bottom") + func allRowsInOrder() { + let items = AttachMenuItem.items(showsCash: true, acceptsMedia: true, attachedCount: 0) + #expect(items == [.cash, .camera, .photos]) + } + + @Test("An E2EE chat keeps Cash and drops Camera and Photos") + func mediaRowsHiddenWithoutMedia() { + let items = AttachMenuItem.items(showsCash: true, acceptsMedia: false, attachedCount: 0) + #expect(items == [.cash]) + } + + @Test("A chat with no Send Cash target lists only the media rows") + func cashRowHiddenWithoutCash() { + let items = AttachMenuItem.items(showsCash: false, acceptsMedia: true, attachedCount: 0) + #expect(items == [.camera, .photos]) + } + + @Test("A chat that takes neither cash nor media has no rows") + func noRows() { + let items = AttachMenuItem.items(showsCash: false, acceptsMedia: false, attachedCount: 0) + #expect(items.isEmpty) + } + + @Test("Camera and Photos stay until the composer is full", arguments: [1, 5, 9]) + func mediaRowsBelowLimit(attachedCount: Int) { + let items = AttachMenuItem.items(showsCash: true, acceptsMedia: true, attachedCount: attachedCount) + #expect(items == [.cash, .camera, .photos]) + } + + @Test("Camera and Photos leave once the composer holds the maximum") + func mediaRowsHiddenAtLimit() { + let items = AttachMenuItem.items( + showsCash: true, + acceptsMedia: true, + attachedCount: ComposerModel.maxAttachments + ) + #expect(items == [.cash]) + } + + @Test("The picker may add only what is left of the maximum", arguments: [ + (0, 10), + (3, 7), + (9, 1), + (10, 0), + ]) + func selectionLimit(attachedCount: Int, expected: Int) { + #expect(AttachMenuItem.photosSelectionLimit(attachedCount: attachedCount) == expected) + } +} diff --git a/FlipcashTests/Chat/ChatCameraSheetTests.swift b/FlipcashTests/Chat/ChatCameraSheetTests.swift new file mode 100644 index 000000000..9e9d66d26 --- /dev/null +++ b/FlipcashTests/Chat/ChatCameraSheetTests.swift @@ -0,0 +1,48 @@ +// +// ChatCameraSheetTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import AVFoundation +import Testing +@testable import Flipcash + +@MainActor +@Suite("Chat camera sheet") +struct ChatCameraSheetTests { + + @Test("First use asks for camera access") + func notDeterminedRequestsAccess() { + #expect(ChatCameraPhase(status: .notDetermined) == .requestingAccess) + } + + @Test("Granted access shows the live camera") + func authorizedIsLive() { + #expect(ChatCameraPhase(status: .authorized) == .live) + } + + @Test("Denied or restricted access shows the Settings prompt", arguments: [ + AVAuthorizationStatus.denied, + .restricted, + ]) + func refusedShowsSettingsPrompt(status: AVAuthorizationStatus) { + #expect(ChatCameraPhase(status: status) == .denied) + } + + @Test("A flip alternates between the back and front lenses", arguments: [ + (AVCaptureDevice.Position.back, AVCaptureDevice.Position.front), + (.front, .back), + (.unspecified, .back), + ]) + func flipAlternatesLenses(from: AVCaptureDevice.Position, to: AVCaptureDevice.Position) { + #expect(ChatPhotoCamera.flipped(from) == to) + } + + @Test("The shutter on a camera that never started returns no photo") + func captureBeforeStartReturnsNil() async { + let camera = ChatPhotoCamera() + #expect(await camera.capture() == nil) + } +} diff --git a/FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift b/FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift new file mode 100644 index 000000000..402264a2b --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift @@ -0,0 +1,64 @@ +// +// ChatMediaBubbleVectorTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashUI + +@Suite("Chat media bubble vectors") +struct ChatMediaBubbleVectorTests { + + private final class BundleToken {} + + struct Vector: Decodable { + struct Expected: Decodable { + let width: Double + let height: Double + let cropped: Bool + } + let name: String + let imageWidth: Double + let imageHeight: Double + let maxWidth: Double + let expected: Expected + } + + private struct Fixture: Decodable { + let minAspect: Double + let maxAspect: Double + let bubble: [Vector] + } + + private static func loadFixture() throws -> Fixture { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test("The clamp bounds match the fixture") + func clampBoundsMatchFixture() throws { + let fixture = try Self.loadFixture() + #expect(ChatMediaBubbleSizing.minAspect == fixture.minAspect) + #expect(ChatMediaBubbleSizing.maxAspect == fixture.maxAspect) + } + + @Test("The bubble size matches every bubble vector") + func matchesFixture() throws { + let vectors = try Self.loadFixture().bubble + #expect(!vectors.isEmpty) + + for vector in vectors { + let size = ChatMediaBubbleSizing.size( + imageWidth: vector.imageWidth, + imageHeight: vector.imageHeight, + maxWidth: vector.maxWidth + ) + #expect(abs(size.width - vector.expected.width) < 0.001, "\(vector.name)") + #expect(abs(size.height - vector.expected.height) < 0.001, "\(vector.name)") + #expect(size.cropped == vector.expected.cropped, "\(vector.name)") + } + } +} diff --git a/FlipcashTests/Chat/ChatMediaCellTests.swift b/FlipcashTests/Chat/ChatMediaCellTests.swift new file mode 100644 index 000000000..f9259ba50 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaCellTests.swift @@ -0,0 +1,112 @@ +// +// ChatMediaCellTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo cell") +@MainActor +struct ChatMediaCellTests { + + private static let blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj" + private static let remoteURL = URL(string: "https://example.com/a.jpg")! + private static let pill = ReactionPill(emoji: "🔥", count: 1, selfReacted: false, pending: false) + + private func media(caption: String? = nil, isRedacted: Bool = false, blurhash: String? = Self.blurhash) -> ChatMediaContent { + ChatMediaContent( + blobID: BlobID(data: Data([1])), + width: 1179, + height: 2556, + blurhash: blurhash, + caption: caption, + isRedacted: isRedacted + ) + } + + private func configuredCell( + _ media: ChatMediaContent, + canReact: Bool = true, + localImage: UIImage? = nil, + remoteURL: URL? = nil + ) -> ChatMediaCell { + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure( + with: ChatMessage(id: "1", content: .media(media), sender: .other, reactions: [Self.pill], canReact: canReact), + maxWidth: 240, + localImage: localImage, + remoteURL: remoteURL + ) + return cell + } + + @Test("A row with no image yet draws its BlurHash") + func drawsBlurhashBeforeImageLoads() { + let cell = configuredCell(media()) + #expect(cell.imageView.image != nil) + } + + @Test("A pending row draws the staged local image") + func drawsLocalImage() { + let image = UIImage(systemName: "photo")! + let cell = configuredCell(media(), localImage: image) + #expect(cell.imageView.image === image) + } + + @Test("A caption shows as its own bubble") + func showsCaption() { + let cell = configuredCell(media(caption: "hi")) + #expect(cell.captionBubble.isHidden == false) + #expect(cell.captionLabel.text == "hi") + } + + @Test("A nil or empty caption hides the caption bubble", arguments: [nil, ""] as [String?]) + func hidesEmptyCaption(caption: String?) { + let cell = configuredCell(media(caption: caption)) + #expect(cell.captionBubble.isHidden) + } + + @Test("A viewable row shows its reactions and takes a tap") + func viewableRow() { + let cell = configuredCell(media(), remoteURL: Self.remoteURL) + #expect(!cell.blurhashOnly) + #expect(!cell.reactionRow.isHidden) + #expect(cell.imageTap.isEnabled) + } + + @Test("A redacted row draws only its BlurHash, even handed a URL") + func redactedRowIsBlurhashOnly() { + let local = UIImage(systemName: "photo")! + let cell = configuredCell(media(isRedacted: true), localImage: local, remoteURL: Self.remoteURL) + #expect(cell.blurhashOnly) + #expect(cell.reactionRow.isHidden) + #expect(!cell.imageTap.isEnabled) + #expect(cell.imageView.image != nil) + #expect(cell.imageView.image !== local) + } + + @Test("A previewer's row draws only its BlurHash") + func previewingRowIsBlurhashOnly() { + let cell = configuredCell(media(), canReact: false) + #expect(cell.blurhashOnly) + #expect(cell.reactionRow.isHidden) + #expect(!cell.imageTap.isEnabled) + } + + @Test("A photo caches under its blob, so a freshly signed URL reuses the download") + func cachesUnderBlob() { + let blobID = BlobID(data: Data([1])) + let first = ChatMediaImageSource.resource(blobID: blobID, url: URL(string: "https://cdn.example.com/a.jpg?sig=1")!) + let second = ChatMediaImageSource.resource(blobID: blobID, url: URL(string: "https://cdn.example.com/a.jpg?sig=2")!) + let other = ChatMediaImageSource.resource(blobID: BlobID(data: Data([2])), url: URL(string: "https://cdn.example.com/a.jpg?sig=1")!) + + #expect(first.cacheKey == second.cacheKey) + #expect(first.cacheKey != other.cacheKey) + #expect(second.downloadURL.absoluteString.hasSuffix("sig=2")) + } +} diff --git a/FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift b/FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift new file mode 100644 index 000000000..b1cf6fb52 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift @@ -0,0 +1,76 @@ +// +// ChatMediaFanOutVectorTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Chat media fan-out vectors") +struct ChatMediaFanOutVectorTests { + + private final class BundleToken {} + + struct ExpectedMessage: Decodable { + let kind: String + let chip: String? + let caption: String? + let text: String? + let replyTo: String? + } + + struct Vector: Decodable { + let name: String + let chips: [String] + let text: String? + let replyTo: String? + let messages: [ExpectedMessage] + } + + private struct Fixture: Decodable { + let fanOut: [Vector] + } + + /// The fixture's message names mapped onto ids; only the identity matters. + static let replyIDs: [String: MessageID] = ["m1": MessageID(value: 7)] + + static func loadVectors() throws -> [Vector] { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)).fanOut + } + + @Test("The send plan matches every fanOut vector") + func matchesFixture() throws { + let vectors = try Self.loadVectors() + #expect(!vectors.isEmpty) + + for vector in vectors { + let plan = ChatMediaSendPlan.messages( + chips: vector.chips, + text: vector.text, + replyTo: vector.replyTo.flatMap { Self.replyIDs[$0] } + ) + #expect(plan.count == vector.messages.count, "\(vector.name)") + + for (built, expected) in zip(plan, vector.messages) { + let expectedReply = expected.replyTo.flatMap { Self.replyIDs[$0] } + switch built { + case .text(let text, let replyTo): + #expect(expected.kind == "text", "\(vector.name)") + #expect(text == expected.text, "\(vector.name)") + #expect(replyTo == expectedReply, "\(vector.name)") + case .media(let message): + #expect(expected.kind == "media", "\(vector.name)") + #expect(message.chip == expected.chip, "\(vector.name)") + #expect(message.caption == expected.caption, "\(vector.name)") + #expect(message.replyTo == expectedReply, "\(vector.name)") + } + } + } + } +} diff --git a/FlipcashTests/Chat/ChatMediaStringVectorTests.swift b/FlipcashTests/Chat/ChatMediaStringVectorTests.swift new file mode 100644 index 000000000..c313024d0 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaStringVectorTests.swift @@ -0,0 +1,123 @@ +// +// ChatMediaStringVectorTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Chat media string vectors") +@MainActor +struct ChatMediaStringVectorTests { + + private final class BundleToken {} + + struct Vector: Decodable { + struct Expected: Decodable { + let snippet: String + let preview: String + } + let name: String + let caption: String? + let expected: Expected + } + + private struct Fixture: Decodable { + let strings: [Vector] + } + + private static func vectors() throws -> [Vector] { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + let vectors = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)).strings + #expect(!vectors.isEmpty) + return vectors + } + + private func photoMessage(caption: String?) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 1), + senderID: nil, + content: .media([MediaAttachment(blobID: BlobID(data: Data([1])), width: 100, height: 100, blurhash: nil)], caption: caption), + date: Date(timeIntervalSince1970: 1), + unreadSeq: 1 + ) + } + + private func conversation(lastMessage: ConversationMessage) -> Conversation { + Conversation( + id: ConversationID.test(1), + members: [], + lastMessage: lastMessage, + lastActivity: Date(timeIntervalSince1970: 1) + ) + } + + @Test("The quote snippet and list preview match every strings vector") + func helperMatchesFixture() throws { + for vector in try Self.vectors() { + #expect(ChatMediaStrings.quoteSnippet(caption: vector.caption) == vector.expected.snippet, "\(vector.name)") + #expect(ChatMediaStrings.listPreview(caption: vector.caption) == vector.expected.preview, "\(vector.name)") + } + } + + @Test("The conversation list previews a photo with the fixture's preview") + func conversationListMatchesFixture() throws { + let mock = MockConversations() + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: try Database.makeTemp().database, + owner: .generate()!, selfUserID: UUID(), + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + + for vector in try Self.vectors() { + let preview = controller.lastMessagePreview(for: conversation(lastMessage: photoMessage(caption: vector.caption))) { _ -> String? in nil } + #expect(preview == vector.expected.preview, "\(vector.name)") + } + } + + @Test("Spotlight describes a photo with the fixture's preview") + func spotlightMatchesFixture() throws { + for vector in try Self.vectors() { + let item = ChatSpotlightItem( + conversation: conversation(lastMessage: photoMessage(caption: vector.caption)), + displayName: "Anna", + counterpartPhoneE164: nil, + thumbnailData: nil + ) + #expect(item.contentDescription == vector.expected.preview, "\(vector.name)") + } + } + + @Test("A reply to a photo quotes it with the fixture's snippet") + func quoteMatchesFixture() throws { + for vector in try Self.vectors() { + let original = photoMessage(caption: vector.caption) + let reply = ConversationMessage( + id: MessageID(value: 2), + senderID: nil, + content: .text("nice"), + date: Date(timeIntervalSince1970: 2), + unreadSeq: 2, + repliedTo: original.id + ) + let quotes = ChatItem.from( + [original, reply], + selfUserID: UUID(), + cashBranding: { _ in ("Cash", nil) }, + counterpartName: "Ada", + quotedMessage: { $0 == original.id ? original : nil } + ).compactMap { item in + if case .message(let message) = item { return message.quote } else { return nil } + } + let quote = try #require(quotes.first) + #expect(quote.snippet == vector.expected.snippet, "\(vector.name)") + } + } +} diff --git a/FlipcashTests/Chat/ChatMediaURLResolverTests.swift b/FlipcashTests/Chat/ChatMediaURLResolverTests.swift new file mode 100644 index 000000000..9b51bd4c3 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaURLResolverTests.swift @@ -0,0 +1,144 @@ +// +// ChatMediaURLResolverTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo URL resolver") +@MainActor +struct ChatMediaURLResolverTests { + + private let blobID = BlobID(data: Data([1])) + private let url = URL(string: "https://example.com/a.jpg")! + + /// Records every blob the resolver asks for. + @MainActor + private final class FetchSpy { + var calls: [BlobID] = [] + } + + private func photo(isRedacted: Bool) -> ChatMediaContent { + ChatMediaContent(blobID: blobID, width: 100, height: 100, blurhash: nil, caption: nil, isRedacted: isRedacted) + } + + @Test("A BlurHash-only row never fetches") + func blurhashOnlyRowNeverFetches() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + #expect(resolver.url(for: photo(isRedacted: true), canReact: true) { _ in } == nil) + #expect(resolver.url(for: photo(isRedacted: false), canReact: false) { _ in } == nil) + + // Give any stray fetch task a chance to run before asserting none did. + await Task.yield() + #expect(spy.calls.isEmpty) + } + + @Test("Joining fetches once, then serves from the cache") + func joiningFetchesOnceThenServesFromCache() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + let photo = photo(isRedacted: false) + + #expect(resolver.url(for: photo, canReact: false) { _ in } == nil) + + let resolved = await withCheckedContinuation { continuation in + let immediate = resolver.url(for: photo, canReact: true) { continuation.resume(returning: $0) } + #expect(immediate == nil) + // A second dequeue while the first fetch is in flight does not start another. + #expect(resolver.url(for: photo, canReact: true) { _ in } == nil) + } + #expect(resolved == url) + + #expect(resolver.url(for: photo, canReact: true) { _ in } == url) + #expect(spy.calls == [blobID]) + } + + @Test("A quote's thumbnail never fetches for a redacted original, a previewer, or a non-photo") + func quoteThumbnailNeverFetchesWhenBlurhashOnly() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + #expect(resolver.thumbnailURL(for: .media(thumbnailBlobID: nil), canReact: true) { _ in } == nil) + #expect(resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: false) { _ in } == nil) + #expect(resolver.thumbnailURL(for: .text, canReact: true) { _ in } == nil) + #expect(resolver.thumbnailURL(for: .unavailable, canReact: true) { _ in } == nil) + + await Task.yield() + #expect(spy.calls.isEmpty) + } + + @Test("A quote's thumbnail shares the photo row's cached URL") + func quoteThumbnailSharesTheRowCache() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + let resolved = await withCheckedContinuation { continuation in + let immediate = resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) { + continuation.resume(returning: $0) + } + #expect(immediate == nil) + } + #expect(resolved == url) + + #expect(resolver.url(for: photo(isRedacted: false), canReact: true) { _ in } == url) + #expect(spy.calls == [blobID]) + } + + @Test("The composer's reply strip waits for the thumbnail, and never fetches a redacted one") + func awaitedThumbnail() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: nil), canReact: true) == nil) + #expect(spy.calls.isEmpty) + + #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) == url) + #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) == url) + #expect(spy.calls == [blobID]) + } + + @Test("Every caller waiting on one fetch hears when it lands") + func everyWaiterIsCalledBack() async { + let url = url + let resolver = ChatMediaURLResolver { _ in url } + let photo = photo(isRedacted: false) + + let (row, quote) = await withCheckedContinuation { continuation in + var row: URL? + let immediate = resolver.url(for: photo, canReact: true) { row = $0 } + #expect(immediate == nil) + _ = resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) { quote in + continuation.resume(returning: (row, quote)) + } + } + #expect(row == url) + #expect(quote == url) + } +} diff --git a/FlipcashTests/Chat/ChatMediaUploaderTests.swift b/FlipcashTests/Chat/ChatMediaUploaderTests.swift new file mode 100644 index 000000000..27e9722ea --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaUploaderTests.swift @@ -0,0 +1,246 @@ +// +// ChatMediaUploaderTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +@testable import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Chat media upload") +struct ChatMediaUploaderTests { + + private final class BundleToken {} + + private struct Fixture: Decodable { + let uploadMimeType: String + let jpegQualityLadder: [Double] + } + + private func loadFixture() throws -> Fixture { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + // MARK: - Uploader - + + @Test("Uploads as the fixture's MIME type through the fixture's quality ladder") + func uploadsFixtureMimeType() async throws { + let fixture = try loadFixture() + #expect(ChatMediaEncoder.qualityLadder == fixture.jpegQualityLadder) + + let blob = MockChatMediaBlobStore() + _ = try await ChatMediaUploader(blob: blob).upload(Self.image(width: 40, height: 30)) { _, _ in } + + #expect(blob.storedMimeTypes == [fixture.uploadMimeType]) + } + + @Test("Reports the downscale target before storing, then awaits finalization") + func reportsDimensionsBeforeStoring() async throws { + let blob = MockChatMediaBlobStore(policy: Self.policy(maxSizeBytes: 5_000_000, maxEdge: 2048)) + var prepared: (width: Int, height: Int, storesSoFar: Int)? + + let blobID = try await ChatMediaUploader(blob: blob).upload(Self.image(width: 3000, height: 2000)) { width, height in + prepared = (width, height, blob.storedData.count) + } + + #expect(prepared?.width == 2048) + #expect(prepared?.height == 1365) + #expect(prepared?.storesSoFar == 0) + #expect(blobID == MockChatMediaBlobStore.blobID) + #expect(blob.finalizedBlobIDs == [blobID]) + + let stored = try #require(blob.storedData.first) + #expect(stored.count <= 5_000_000) + let decoded = try #require(UIImage(data: stored)?.cgImage) + #expect(decoded.width == 2048) + #expect(decoded.height == 1365) + } + + @Test("A sideways capture is downscaled in display space") + func sidewaysCaptureUsesDisplaySpace() async throws { + let landscape = try #require(Self.image(width: 300, height: 200).cgImage) + let portrait = UIImage(cgImage: landscape, scale: 1, orientation: .right) + let blob = MockChatMediaBlobStore(policy: Self.policy(maxSizeBytes: 5_000_000, maxEdge: 150)) + var prepared: (width: Int, height: Int)? + + _ = try await ChatMediaUploader(blob: blob).upload(portrait) { prepared = ($0, $1) } + + #expect(prepared?.width == 100) + #expect(prepared?.height == 150) + let stored = try #require(blob.storedData.first) + let decoded = try #require(UIImage(data: stored)?.cgImage) + #expect(decoded.width == 100) + #expect(decoded.height == 150) + } + + @Test("A moderation rejection at finalization is not retryable") + func moderationRejectionIsTerminal() async { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob)) + + #expect(error?.isRetryable == false) + #expect(blob.storedData.count == 1) + } + + @Test("A rejection when storing is not retried automatically") + func storeRejectionIsNotRetried() async { + let blob = MockChatMediaBlobStore() + blob.storeResults = [.failure(ErrorBlob.rejected(.moderation))] + + let error = await Self.uploadError(ChatMediaUploader(blob: blob, backoff: [.zero, .zero])) + + #expect(error?.isRetryable == false) + #expect(blob.storeAttempts == 1) + #expect(blob.finalizedBlobIDs.isEmpty) + } + + @Test("A network failure is retried through the backoff, then surfaces as retryable") + func networkFailureRetriesThenIsRetryable() async { + let blob = MockChatMediaBlobStore() + blob.storeResults = Array(repeating: .failure(ErrorBlob.network(URLError(.networkConnectionLost))), count: 3) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob, backoff: [.zero, .zero])) + + #expect(error?.isRetryable == true) + #expect(blob.storeAttempts == 3) + } + + @Test("A network failure that clears within the backoff uploads normally") + func networkFailureRecovers() async throws { + let blob = MockChatMediaBlobStore() + blob.storeResults = [.failure(ErrorBlob.network(URLError(.timedOut)))] + + let blobID = try await ChatMediaUploader(blob: blob, backoff: [.zero]).upload(Self.image(width: 40, height: 30)) { _, _ in } + + #expect(blobID == MockChatMediaBlobStore.blobID) + #expect(blob.storeAttempts == 2) + } + + @Test("A finalization timeout is retryable") + func finalizationTimeoutIsRetryable() async { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.timedOut) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob)) + + #expect(error?.isRetryable == true) + } + + @Test("A policy with no constraint for JPEG uploads nothing") + func noMatchingConstraintUploadsNothing() async { + let policy = UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init(pattern: "video/*", maxSizeBytes: 5_000_000, image: nil), + ]) + let blob = MockChatMediaBlobStore(policy: policy) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob)) + + #expect(error?.isRetryable == false) + #expect(blob.storeAttempts == 0) + } + + // MARK: - Chip - + + @Test("A staged chip uploads, with its dimensions set before the bytes are stored") + func stagedChipUploads() async throws { + let blob = MockChatMediaBlobStore(policy: Self.policy(maxSizeBytes: 5_000_000, maxEdge: 20)) + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: blob))) + var dimensionsAtStore: (Int?, Int?)? + blob.onStore = { dimensionsAtStore = (chip.preparedWidth, chip.preparedHeight) } + + #expect(chip.state == .preparing) + + let blobID = try await #require(chip.uploadTask).value + + #expect(dimensionsAtStore?.0 == 20) + #expect(dimensionsAtStore?.1 == 15) + #expect(chip.state == .uploaded(blobID)) + } + + @Test("A moderated chip fails for good") + func moderatedChipFailsForGood() async throws { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: blob))) + + _ = await chip.uploadTask?.result + + #expect(chip.state == .failed(.notRetryable)) + } + + @Test("Retrying a failed chip uploads it again") + func retryingFailedChipUploadsAgain() async throws { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.timedOut) + let uploader = ChatMediaUploader(blob: blob) + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: uploader)) + _ = await chip.uploadTask?.result + #expect(chip.state == .failed(.retryable)) + + blob.finalization = .success(()) + chip.startUpload(using: uploader) + let blobID = try await #require(chip.uploadTask).value + + #expect(chip.state == .uploaded(blobID)) + } + + @Test("Removing a chip cancels its upload") + func removingChipCancelsUpload() throws { + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: MockChatMediaBlobStore()))) + + composer.removeChip(chip.id) + + #expect(composer.chips.isEmpty) + #expect(chip.uploadTask?.isCancelled == true) + } + + // MARK: - Helpers - + + private static func image(width: Int, height: Int) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let size = CGSize(width: width, height: height) + return UIGraphicsImageRenderer(size: size, format: format).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } + + private static func policy(maxSizeBytes: Int, maxEdge: Int) -> UploadPolicy { + UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init( + pattern: "image/*", + maxSizeBytes: maxSizeBytes, + image: .init(maxWidth: maxEdge, maxHeight: maxEdge, maxPixels: 0) + ), + ]) + } + + private static func uploadError(_ uploader: ChatMediaUploader) async -> ChatMediaUploadError? { + do { + _ = try await uploader.upload(image(width: 40, height: 30)) { _, _ in } + return nil + } catch { + return error as? ChatMediaUploadError + } + } + + @Test("A refused photo reports as an expected outcome, a client defect as an error") + func reportingLevels() { + #expect(ChatMediaUploadError.rejected(.moderation).reportingLevel == .info) + #expect(ChatMediaUploadError.noMatchingConstraint.reportingLevel == .error) + #expect(ChatMediaUploadError.failed(ErrorBlob.quotaExceeded).reportingLevel == .info) + #expect(ChatMediaUploadError.failed(ErrorBlob.unknown).reportingLevel == .error) + } +} diff --git a/FlipcashTests/Chat/ChatMediaViewerTests.swift b/FlipcashTests/Chat/ChatMediaViewerTests.swift new file mode 100644 index 000000000..53da3ca17 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaViewerTests.swift @@ -0,0 +1,178 @@ +// +// ChatMediaViewerTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo viewer") +@MainActor +struct ChatMediaViewerTests { + + private static let blobID = BlobID(data: Data([1])) + private static let remoteURL = URL(string: "https://example.com/a.jpg")! + private static let localImage = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 30)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 30)) + } + + private func media(isRedacted: Bool = false) -> ChatMediaContent { + ChatMediaContent(blobID: Self.blobID, width: 40, height: 30, blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", caption: nil, isRedacted: isRedacted) + } + + private func message( + _ media: ChatMediaContent, + canReact: Bool = true, + receipt: ChatReceipt? = nil + ) -> ChatMessage { + ChatMessage(id: "photo", content: .media(media), sender: .other, receipt: receipt, canReact: canReact) + } + + private func request( + _ message: ChatMessage, + localImage: UIImage? = nil, + remoteURL: URL? = ChatMediaViewerTests.remoteURL + ) -> ChatMediaViewerRequest? { + ChatMediaViewerRequest(message: message, localImage: localImage, remoteURL: remoteURL, placeholder: nil) { nil } + } + + // MARK: - Request + + @Test("A viewable photo opens with its blob and URL") + func viewablePhotoOpens() throws { + let request = try #require(request(message(media()))) + #expect(request.blobID == Self.blobID) + #expect(request.remoteURL == Self.remoteURL) + } + + @Test("A redacted photo, or one seen while previewing a group, never opens") + func blurhashOnlyNeverOpens() { + #expect(request(message(media(isRedacted: true))) == nil) + #expect(request(message(media(), canReact: false)) == nil) + #expect(request(message(media(isRedacted: true)), localImage: Self.localImage) == nil) + } + + @Test("A failed send opens nothing: its tap is the retry") + func failedRowNeverOpens() { + #expect(request(message(media(), receipt: .failed("Not delivered")), localImage: Self.localImage) == nil) + } + + @Test("A photo with nothing to show yet opens nothing") + func unresolvedPhotoNeverOpens() { + #expect(request(message(media()), remoteURL: nil) == nil) + } + + @Test("A pending send opens on its staged image") + func pendingSendOpens() throws { + let request = try #require(request(message(media()), localImage: Self.localImage, remoteURL: nil)) + #expect(request.localImage === Self.localImage) + } + + // MARK: - Viewer + + @Test("The viewer zooms in from the tapped photo") + func zoomsFromSource() throws { + let source = UIView() + let request = try #require(ChatMediaViewerRequest( + message: message(media()), localImage: Self.localImage, remoteURL: nil, placeholder: nil + ) { source }) + let viewer = ChatMediaViewerController(request: request) { _ in } + + #expect(viewer.preferredTransition != nil) + #expect(request.sourceView() === source) + } + + @Test("Pinch zooms the photo itself, past its fitted size") + func pinchZoomsThePhoto() throws { + let viewer = ChatMediaViewerController(request: try #require(request(message(media()), localImage: Self.localImage))) { _ in } + viewer.loadViewIfNeeded() + + #expect(viewer.scrollView.maximumZoomScale > viewer.scrollView.minimumZoomScale) + #expect(viewer.viewForZooming(in: viewer.scrollView) === viewer.imageView) + } + + @Test("Double-tap zooms in, and a second double-tap zooms back out") + func doubleTapTogglesZoom() { + let scale = ChatMediaViewerController.doubleTapTargetScale + #expect(scale(1, 1) > 1) + #expect(scale(2.5, 1) == 1) + } + + @Test("Share hands the loaded photo to the share sheet") + func sharesLoadedPhoto() throws { + var shared: UIImage? + let viewer = ChatMediaViewerController(request: try #require(request(message(media()), localImage: Self.localImage))) { shared = $0 } + viewer.loadViewIfNeeded() + + #expect(viewer.shareButton.isEnabled) + viewer.shareButton.sendActions(for: .touchUpInside) + #expect(shared === Self.localImage) + } + + @Test("Share waits until the photo itself has loaded") + func shareWaitsForPhoto() throws { + let placeholder = UIImage(systemName: "photo")! + let request = try #require(ChatMediaViewerRequest( + message: message(media()), localImage: nil, remoteURL: URL(string: "https://example.invalid/never.jpg")!, placeholder: placeholder + ) { nil }) + var shared: UIImage? + let viewer = ChatMediaViewerController(request: request) { shared = $0 } + viewer.loadViewIfNeeded() + + #expect(viewer.imageView.image === placeholder) + #expect(!viewer.shareButton.isEnabled) + viewer.shareButton.sendActions(for: .touchUpInside) + #expect(shared == nil) + } + + // MARK: - Transcript wiring + + private func transcript(_ message: ChatMessage, localImage: UIImage? = nil) -> (ChatViewController, ChatMediaCell) { + let controller = ChatViewController() + controller.pendingMediaImage = { _ in localImage } + controller.loadViewIfNeeded() + controller.update(items: [.message(message)]) + let cell = controller.collectionView(controller.collectionView, cellForItemAt: IndexPath(item: 0, section: 0)) + return (controller, cell as! ChatMediaCell) + } + + @Test("Tapping a viewable photo asks the owner to open it") + func tapOpensViewer() { + let (controller, cell) = transcript(message(media()), localImage: Self.localImage) + var opened: ChatMediaViewerRequest? + controller.onMediaTap = { opened = $0 } + + cell.onImageTap?() + + #expect(opened?.blobID == Self.blobID) + #expect(opened?.localImage === Self.localImage) + } + + @Test("A redacted photo's tap, even forced, opens nothing") + func blurhashOnlyTapOpensNothing() { + let (controller, cell) = transcript(message(media(isRedacted: true)), localImage: Self.localImage) + var opened = false + controller.onMediaTap = { _ in opened = true } + + #expect(!cell.imageTap.isEnabled) + cell.onImageTap?() + + #expect(!opened) + } + + @Test("The photo's tap leaves the transcript's keyboard-dismiss tap free to fire") + func photoTapDoesNotSwallowKeyboardDismiss() throws { + let (controller, cell) = transcript(message(media()), localImage: Self.localImage) + let dismissTap = try #require(controller.collectionView.gestureRecognizers?.first { + $0 is UITapGestureRecognizer && $0.delegate === controller + } as? UITapGestureRecognizer) + + #expect(!dismissTap.cancelsTouchesInView) + #expect(controller.gestureRecognizer(dismissTap, shouldRecognizeSimultaneouslyWith: cell.imageTap)) + } +} diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index cefc0b4ed..6a7e63259 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -258,6 +258,32 @@ struct ChatMessageMappingTests { #expect(ChatCashContent.caption(isFromSelf: false, isTip: cash.isTip) == "You received") } + @Test("Photo messages map to media content, a redacted one staying a photo", arguments: [false, true]) + func media(redacted: Bool) { + let attachment = MediaAttachment(blobID: BlobID(data: Data([9, 8, 7])), width: 300, height: 400, blurhash: "LEHV6nWB2yk8") + let messages = [ + ConversationMessage( + id: MessageID(value: 1), + senderID: them, + content: .media([attachment], caption: "hi"), + date: base, + unreadSeq: 1, + redacted: redacted + ), + ] + + let rows = messageRows(ChatItem.from(messages, selfUserID: me)) + + #expect(rows.map(\.content) == [.media(ChatMediaContent( + blobID: attachment.blobID, + width: 300, + height: 400, + blurhash: "LEHV6nWB2yk8", + caption: "hi", + isRedacted: redacted + ))]) + } + @Test("Tipped cash messages carry the tip flag and caption") func tippedCash() { let fiat = ExchangedFiat( diff --git a/FlipcashTests/Chat/ChatPhotoStagingTests.swift b/FlipcashTests/Chat/ChatPhotoStagingTests.swift new file mode 100644 index 000000000..2e5a171ba --- /dev/null +++ b/FlipcashTests/Chat/ChatPhotoStagingTests.swift @@ -0,0 +1,214 @@ +// +// ChatPhotoStagingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Chat photo staging") +struct ChatPhotoStagingTests { + + private let uploader = ChatMediaUploader(blob: MockChatMediaBlobStore()) + + private func images(_ count: Int) -> [UIImage] { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return (0.. UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3), format: format).image { _ in } + } + + @Test("Text alone sends as text") + func textAloneSendsText() { + let composer = ComposerModel() + composer.draft = " hello " + + switch composer.outgoing { + case .text(let text): + #expect(text == "hello") + case .media, .none: + Issue.record("Expected a text send") + } + } + + @Test("Staged photos send as media, captioned with the text") + func chipsSendAsMediaWithCaption() throws { + let composer = ComposerModel() + let first = try #require(composer.stageChip(image: image(), uploader: uploader)) + let second = try #require(composer.stageChip(image: image(), uploader: uploader)) + composer.draft = " look " + + switch composer.outgoing { + case .media(let chips, let caption): + #expect(chips.map(\.id) == [first.id, second.id]) + #expect(caption == "look") + case .text, .none: + Issue.record("Expected a media send") + } + } + + @Test("Staged photos with no text send without a caption") + func chipsWithoutTextSendWithoutCaption() throws { + let composer = ComposerModel() + try #require(composer.stageChip(image: image(), uploader: uploader) != nil) + + switch composer.outgoing { + case .media(let chips, let caption): + #expect(chips.count == 1) + #expect(caption == nil) + case .text, .none: + Issue.record("Expected a media send") + } + } + + @Test("A reply with staged photos still sends as media") + func replyWithChipsSendsMedia() throws { + let composer = ComposerModel() + composer.beginReplying(to: ComposerModel.ReplyTarget( + messageID: MessageID(value: 7), + stableID: "7", + authorName: "Ana", + authorID: nil, + snippet: "hi" + )) + try #require(composer.stageChip(image: image(), uploader: uploader) != nil) + + switch composer.outgoing { + case .media: + break + case .text, .none: + Issue.record("Expected a media send") + } + } + + @Test("A failed chip holds the send") + func failedChipHoldsSend() throws { + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: image(), uploader: uploader)) + chip.uploadTask?.cancel() + chip.state = .failed(.retryable) + composer.draft = "caption" + + #expect(composer.outgoing == nil) + } + + @Test("An empty composer and an edit send nothing") + func emptyAndEditingSendNothing() { + let composer = ComposerModel() + #expect(composer.outgoing == nil) + + composer.beginEditing(messageID: MessageID(value: 3), stableID: "3", currentText: "original") + composer.draft = "changed" + #expect(composer.outgoing == nil) + } +} + +@MainActor +@Suite("Chat camera slot") +struct ChatCameraSlotTests { + + @Test("A fresh slot is closed at the fallback height") + func freshSlotIsClosed() { + let slot = ChatCameraSlot() + #expect(!slot.isOpen) + #expect(slot.height == ChatCameraSlot.fallbackHeight) + } + + @Test("The slot takes the keyboard's height above the bottom safe area") + func takesKeyboardHeight() { + let slot = ChatCameraSlot() + slot.keyboardWillShow(height: 336, safeAreaBottom: 34) + #expect(slot.height == 302) + } + + @Test("A keyboard rising closes the camera") + func keyboardClosesCamera() { + let slot = ChatCameraSlot() + slot.open() + #expect(slot.isOpen) + + slot.keyboardWillShow(height: 336, safeAreaBottom: 34) + #expect(!slot.isOpen) + } + + @Test("A keyboard no taller than the safe area leaves the height alone") + func ignoresDegenerateKeyboard() { + let slot = ChatCameraSlot() + slot.keyboardWillShow(height: 20, safeAreaBottom: 34) + #expect(slot.height == ChatCameraSlot.fallbackHeight) + } +} diff --git a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift index d4d0ceb33..3d4037b4a 100644 --- a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift +++ b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift @@ -139,6 +139,25 @@ struct ChatQuoteBubbleTests { cell.bubbleView.quotePanel.simulateTap() #expect(tapped == nil) } + + @Test("A quoted photo draws its thumbnail; a redacted one draws none") + func mediaQuote_drawsThumbnailOnlyWhenFetchable() { + let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])))) + let redacted = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: nil)) + + #expect(laidOutCell(quote: photo).bubbleView.quotePanel.thumbnailView.isHidden == false) + #expect(laidOutCell(quote: redacted).bubbleView.quotePanel.thumbnailView.isHidden == true) + } + + @Test("A recycled panel drops the photo it quoted before") + func reuse_dropsThumbnail() { + let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])))) + let cell = laidOutCell(quote: photo) + cell.configure(with: ChatMessage(id: "2", content: .text("hi"), sender: .me, quote: quote), maxWidth: Self.maxWidth) + + #expect(cell.bubbleView.quotePanel.thumbnailView.isHidden == true) + #expect(cell.bubbleView.quotePanel.thumbnailView.image == nil) + } } @Suite("Reply bubble geometry") diff --git a/FlipcashTests/Chat/ComposerChipStripTests.swift b/FlipcashTests/Chat/ComposerChipStripTests.swift new file mode 100644 index 000000000..0b74624bf --- /dev/null +++ b/FlipcashTests/Chat/ComposerChipStripTests.swift @@ -0,0 +1,49 @@ +// +// ComposerChipStripTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Composer chip strip") +struct ComposerChipStripTests { + + @Test("A chip still preparing or uploading shows the progress ring", arguments: [ + ComposerChip.State.preparing, + .uploading, + ]) + func inFlightShowsProgress(state: ComposerChip.State) { + #expect(ComposerChipBadge(state) == .progress) + } + + @Test("An uploaded chip shows only its thumbnail") + func uploadedShowsNothing() { + #expect(ComposerChipBadge(.uploaded(BlobID(data: Data(repeating: 1, count: 32)))) == .none) + } + + @Test("A chip that can upload again offers Retry") + func retryableOffersRetry() { + #expect(ComposerChipBadge(.failed(.retryable)) == .retry) + } + + @Test("A chip the server refused shows the error without Retry") + func notRetryableShowsError() { + #expect(ComposerChipBadge(.failed(.notRetryable)) == .error) + } + + @Test("The strip shows for staged chips outside an edit", arguments: [ + (0, false, false), + (1, false, true), + (10, false, true), + (1, true, false), + ]) + func stripVisibility(chipCount: Int, isEditing: Bool, expected: Bool) { + #expect(ComposerChipStrip.isShown(chipCount: chipCount, isEditing: isEditing) == expected) + } +} diff --git a/FlipcashTests/Chat/ComposerChipTests.swift b/FlipcashTests/Chat/ComposerChipTests.swift new file mode 100644 index 000000000..3adb8f1b6 --- /dev/null +++ b/FlipcashTests/Chat/ComposerChipTests.swift @@ -0,0 +1,118 @@ +// +// ComposerChipTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Composer chips") +struct ComposerChipTests { + + private final class BundleToken {} + + private struct Fixture: Decodable { + let maxAttachments: Int + } + + private func loadFixture() throws -> Fixture { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + private let uploader = ChatMediaUploader(blob: MockChatMediaBlobStore()) + + private func testImage() -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 8, height: 6), format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 8, height: 6)) + } + } + + @Test("Staging a photo adds a chip that is still preparing") + func stagingAddsPreparingChip() throws { + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + + #expect(composer.chips.map(\.id) == [chip.id]) + #expect(chip.state == .preparing) + #expect(chip.preparedWidth == nil) + #expect(chip.preparedHeight == nil) + } + + @Test("Chips keep the order they were staged in") + func chipsKeepStagingOrder() throws { + let composer = ComposerModel() + let first = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + let second = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + let third = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + + #expect(composer.chips.map(\.id) == [first.id, second.id, third.id]) + } + + @Test("Removing a chip drops only that chip") + func removingDropsOnlyThatChip() throws { + let composer = ComposerModel() + let first = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + let second = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + + composer.removeChip(first.id) + + #expect(composer.chips.map(\.id) == [second.id]) + } + + @Test("The composer refuses a chip past the fixture's maxAttachments") + func refusesChipPastMaxAttachments() throws { + let fixture = try loadFixture() + #expect(ComposerModel.maxAttachments == fixture.maxAttachments) + + let composer = ComposerModel() + for _ in 0.. ConversationBarLeadingControl { + ConversationBarLeadingControl( + isEditing: isEditing, + chatExists: chatExists, + showsSendCash: showsSendCash, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) + } + + @Test("Before the chat exists, Send Cash stays the full-width call to action", arguments: [true, false]) + func callToActionBeforeChatExists(acceptsMedia: Bool) { + #expect(control(chatExists: false, acceptsMedia: acceptsMedia) == .sendCash) + } + + @Test("Before the chat exists with nothing to send, the slot is empty") + func emptyBeforeChatExistsWithoutCash() { + #expect(control(chatExists: false, showsSendCash: false) == .none) + } + + @Test("Once the chat exists, the slot is the attach menu with every row") + func attachMenuOnceChatExists() { + #expect(control() == .attach([.cash, .camera, .photos])) + } + + @Test("An E2EE chat keeps the attach menu with Cash alone") + func attachMenuCashOnlyWithoutMedia() { + #expect(control(acceptsMedia: false) == .attach([.cash])) + } + + @Test("A chat that takes neither cash nor media leaves the slot empty") + func emptyWithoutCashOrMedia() { + #expect(control(showsSendCash: false, acceptsMedia: false) == .none) + } + + @Test("A full composer in a chat without cash leaves the slot empty") + func emptyWhenFullWithoutCash() { + #expect(control(showsSendCash: false, attachedCount: ComposerModel.maxAttachments) == .none) + } + + @Test("An edit takes the slot whatever else is true", arguments: [true, false]) + func cancelWhileEditing(chatExists: Bool) { + #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) + } +} diff --git a/FlipcashTests/Chat/ConversationMediaSendTests.swift b/FlipcashTests/Chat/ConversationMediaSendTests.swift new file mode 100644 index 000000000..b6a298de3 --- /dev/null +++ b/FlipcashTests/Chat/ConversationMediaSendTests.swift @@ -0,0 +1,321 @@ +// +// ConversationMediaSendTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@MainActor +@Suite("Sending photos") +struct ConversationMediaSendTests { + + private let conversationID = ConversationID.test(1) + + /// Mirrors `ConversationReplySendTests.makeController`. + private func makeController(_ mock: MockConversations) -> ConversationController { + ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: try! Database.makeTemp().database, + owner: .generate()!, selfUserID: UUID(), + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + } + + private func testImage(width: CGFloat = 8, height: CGFloat = 6, scale: CGFloat = 1) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + return UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + } + } + + private func blobID(_ name: String) -> BlobID { + BlobID(data: Data(name.utf8)) + } + + /// A chip whose upload has already finished with `name`'s blob. + private func uploadedChip(_ name: String) -> ComposerChip { + let chip = ComposerChip(image: testImage()) + let blobID = blobID(name) + chip.uploadTask = Task { blobID } + return chip + } + + /// A chip whose upload ends only when the test opens `gate`. + private func gatedChip(_ gate: UploadGate, image: UIImage? = nil) -> ComposerChip { + let chip = ComposerChip(image: image ?? testImage()) + chip.uploadTask = Task { try await gate.wait() } + return chip + } + + /// Yields the main actor until `condition` holds, so a send started in a `Task` can reach its + /// first suspension. + private func yield(until condition: () -> Bool) async { + for _ in 0..<200 where !condition() { + await Task.yield() + } + } + + private func pendingMedia(_ controller: ConversationController) -> [ConversationMessage] { + controller.messages(for: conversationID).filter { + switch $0.content { + case .media: $0.clientMessageID != nil && $0.id == .unassigned + case .text, .cash, .deleted, .encrypted, .widget: false + } + } + } + + private func attachment(of message: ConversationMessage) -> MediaAttachment? { + switch message.content { + case .media(let attachments, _): attachments.first + case .text, .cash, .deleted, .encrypted, .widget: nil + } + } + + // MARK: - Fan-out + + @Test("Each fanOut vector with chips posts one message per chip, caption last, reply first") + func fanOutMatchesFixture() async throws { + for vector in try ChatMediaFanOutVectorTests.loadVectors() where !vector.chips.isEmpty { + let mock = MockConversations() + let controller = makeController(mock) + + let sent = await controller.sendMedia( + vector.chips.map(uploadedChip), + caption: vector.text, + to: conversationID, + repliedTo: vector.replyTo.flatMap { ChatMediaFanOutVectorTests.replyIDs[$0] } + ) + + #expect(sent, "\(vector.name)") + #expect(mock.sentMedia.count == vector.messages.count, "\(vector.name)") + for (call, expected) in zip(mock.sentMedia, vector.messages) { + #expect(expected.kind == "media", "\(vector.name)") + #expect(call.blobID == blobID(try #require(expected.chip)), "\(vector.name)") + #expect(call.caption == expected.caption, "\(vector.name)") + #expect(call.repliedTo == expected.replyTo.flatMap { ChatMediaFanOutVectorTests.replyIDs[$0] }, "\(vector.name)") + } + #expect(pendingMedia(controller).isEmpty, "\(vector.name): every row confirmed") + } + } + + @Test("Every pending bubble appears before any upload has finished") + func insertsAllPendingRowsUpFront() async { + let mock = MockConversations() + let controller = makeController(mock) + let first = UploadGate(), second = UploadGate() + + let send = Task { await controller.sendMedia([gatedChip(first), gatedChip(second)], caption: "hi", to: conversationID) } + await yield { pendingMedia(controller).count == 2 } + + let pending = pendingMedia(controller) + #expect(pending.count == 2) + #expect(pending.allSatisfy { $0.status == .sending }) + #expect(pending.map(\.content.mediaCaption) == [nil, "hi"]) + #expect(mock.sentMedia.isEmpty) + + first.open(.success(blobID("a"))) + second.open(.success(blobID("b"))) + #expect(await send.value) + } + + @Test("A later photo that finishes uploading first still posts second") + func postsStrictlyInChipOrder() async { + let mock = MockConversations() + let controller = makeController(mock) + let slow = UploadGate() + + let send = Task { await controller.sendMedia([gatedChip(slow), uploadedChip("fast")], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 2 } + for _ in 0..<20 { await Task.yield() } + #expect(mock.sentMedia.isEmpty, "the second photo waits for the first") + + slow.open(.success(blobID("slow"))) + #expect(await send.value) + #expect(mock.sentMedia.map(\.blobID) == [blobID("slow"), blobID("fast")]) + } + + @Test("A photo that fails to upload fails only its own bubble") + func failedUploadFailsOnlyItsRow() async throws { + let mock = MockConversations() + let controller = makeController(mock) + let rejected = ComposerChip(image: testImage()) + rejected.uploadTask = Task { throw ChatMediaUploadError.rejected(.moderation) } + + let sent = await controller.sendMedia([rejected, uploadedChip("ok")], caption: nil, to: conversationID) + + #expect(!sent) + #expect(mock.sentMedia.map(\.blobID) == [blobID("ok")]) + let pending = pendingMedia(controller) + #expect(pending.count == 1) + #expect(pending.first?.status == .failed) + #expect(controller.pendingMediaImage(forMessageID: try #require(pending.first?.stableID)) === rejected.image) + } + + @Test("A photo the server refuses to post leaves a failed bubble") + func failedPostFailsTheRow() async { + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let controller = makeController(mock) + + #expect(!(await controller.sendMedia([uploadedChip("a")], caption: "hi", to: conversationID))) + + #expect(pendingMedia(controller).map(\.status) == [.failed]) + } + + // MARK: - Pending bubble + + @Test("The pending bubble carries the upload size once the chip knows it") + func pendingRowUsesPreparedSize() async { + let controller = makeController(MockConversations()) + let gate = UploadGate() + let chip = gatedChip(gate) + chip.preparedWidth = 2048 + chip.preparedHeight = 1536 + + let send = Task { await controller.sendMedia([chip], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 1 } + + let attachment = pendingMedia(controller).first.flatMap(attachment(of:)) + #expect(attachment?.width == 2048) + #expect(attachment?.height == 1536) + #expect(attachment?.blobID == nil) + + gate.open(.success(blobID("a"))) + _ = await send.value + } + + @Test("A chip still preparing sizes its bubble from the source image's pixels") + func pendingRowFallsBackToSourcePixels() async { + let controller = makeController(MockConversations()) + let gate = UploadGate() + let chip = gatedChip(gate, image: testImage(width: 8, height: 6, scale: 2)) + + let send = Task { await controller.sendMedia([chip], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 1 } + + let attachment = pendingMedia(controller).first.flatMap(attachment(of:)) + #expect(attachment?.width == 16) + #expect(attachment?.height == 12) + + gate.open(.success(blobID("a"))) + _ = await send.value + } + + @Test("The local image is served for a pending photo and dropped once it is confirmed") + func localImageLivesUntilConfirmed() async throws { + let controller = makeController(MockConversations()) + let gate = UploadGate() + let chip = gatedChip(gate) + + let send = Task { await controller.sendMedia([chip], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 1 } + let messageID = try #require(pendingMedia(controller).first?.stableID) + #expect(controller.pendingMediaImage(forMessageID: messageID) === chip.image) + + gate.open(.success(blobID("a"))) + _ = await send.value + #expect(controller.pendingMediaImage(forMessageID: messageID) == nil) + } + + // MARK: - Retry + + @Test("Retrying a photo whose post failed posts the same blob under the same client id") + func retryReposts() async throws { + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let controller = makeController(mock) + _ = await controller.sendMedia([uploadedChip("a")], caption: "hi", to: conversationID, repliedTo: MessageID(value: 7)) + let failed = try #require(pendingMedia(controller).first) + let clientMessageID = try #require(failed.clientMessageID) + + mock.mediaSendError = nil + await controller.retry(clientMessageID: clientMessageID, in: conversationID) + + #expect(mock.sentMedia.count == 2) + let retried = try #require(mock.sentMedia.last) + #expect(retried.clientMessageID == clientMessageID) + #expect(retried.blobID == blobID("a")) + #expect(retried.caption == "hi") + #expect(retried.repliedTo == MessageID(value: 7)) + #expect(pendingMedia(controller).isEmpty) + } + + @Test("Retrying a photo whose upload failed uploads it again before posting") + func retryReuploads() async throws { + let mock = MockConversations() + let controller = makeController(mock) + let blob = MockChatMediaBlobStore() + blob.storeResults = [.failure(ErrorBlob.uploadDenied)] + let chip = ComposerChip(image: testImage()) + chip.startUpload(using: ChatMediaUploader(blob: blob, backoff: [])) + + #expect(!(await controller.sendMedia([chip], caption: nil, to: conversationID))) + #expect(chip.state == .failed(.retryable)) + let clientMessageID = try #require(pendingMedia(controller).first?.clientMessageID) + + await controller.retry(clientMessageID: clientMessageID, in: conversationID) + + #expect(blob.storeAttempts == 2) + #expect(mock.sentMedia.map(\.blobID) == [MockChatMediaBlobStore.blobID]) + #expect(pendingMedia(controller).isEmpty) + } + + @Test("A photo refused by moderation is not retried") + func moderationIsNotRetried() async throws { + let mock = MockConversations() + let controller = makeController(mock) + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + let chip = ComposerChip(image: testImage()) + chip.startUpload(using: ChatMediaUploader(blob: blob, backoff: [])) + + _ = await controller.sendMedia([chip], caption: nil, to: conversationID) + let clientMessageID = try #require(pendingMedia(controller).first?.clientMessageID) + + await controller.retry(clientMessageID: clientMessageID, in: conversationID) + + #expect(blob.storeAttempts == 1) + #expect(mock.sentMedia.isEmpty) + #expect(pendingMedia(controller).map(\.status) == [.failed]) + } +} + +/// An upload the test finishes by hand. +@MainActor +private final class UploadGate { + + private var continuation: CheckedContinuation? + private var result: Result? + + func wait() async throws -> BlobID { + if let result { return try result.get() } + return try await withCheckedThrowingContinuation { continuation = $0 } + } + + func open(_ result: Result) { + if let continuation { + continuation.resume(with: result) + self.continuation = nil + } else { + self.result = result + } + } +} + +private extension ConversationMessage.Content { + var mediaCaption: String? { + switch self { + case .media(_, let caption): caption + case .text, .cash, .deleted, .encrypted, .widget: nil + } + } +} diff --git a/FlipcashTests/Chat/ReactionUITests.swift b/FlipcashTests/Chat/ReactionUITests.swift index 43a11021b..dd21fd467 100644 --- a/FlipcashTests/Chat/ReactionUITests.swift +++ b/FlipcashTests/Chat/ReactionUITests.swift @@ -58,6 +58,13 @@ struct ReactionUITests { #expect(message.offersReactionStrip == false) } + @Test("A redacted photo never offers the strip; a viewable one does", arguments: [true, false]) + func redactedPhotoNoStrip(isRedacted: Bool) { + let media = ChatMediaContent(blobID: nil, width: 100, height: 100, blurhash: nil, caption: nil, isRedacted: isRedacted) + let message = Self.makeMessage(content: .media(media), sender: .other) + #expect(message.offersReactionStrip == !isRedacted) + } + @Test("The viewer's own message still in flight never offers the strip") func inFlightSendNoStrip() { let message = Self.makeMessage(content: .text("hi"), sender: .me, receipt: nil, isUnsent: true) diff --git a/FlipcashTests/ChatPreviewMappingTests.swift b/FlipcashTests/ChatPreviewMappingTests.swift index f17d6a06f..73568565e 100644 --- a/FlipcashTests/ChatPreviewMappingTests.swift +++ b/FlipcashTests/ChatPreviewMappingTests.swift @@ -285,4 +285,31 @@ struct ChatPreviewMappingTests { #expect(decoded == items) } + + // MARK: - Photos + + @Test("A photo maps to the transcript's photo row, redaction included") + func photoMapsToMediaRow() { + let blobID = BlobID(data: Data([7])) + let attachment = MediaAttachment(blobID: blobID, width: 30, height: 20, blurhash: "LEHV6nWB2yk8") + let messages = [ + ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([attachment], caption: "from today"), + date: Date(timeIntervalSince1970: 1), unreadSeq: 0 + ), + ConversationMessage( + id: MessageID(value: 2), senderID: otherID, + content: .media([attachment], caption: nil), + date: Date(timeIntervalSince1970: 2), unreadSeq: 0, redacted: true + ), + ] + + let rows = messageRows(ChatItem.preview(from: messages, selfUserID: meID)) + + #expect(rows.map(\.content) == [ + .media(ChatMediaContent(blobID: blobID, width: 30, height: 20, blurhash: "LEHV6nWB2yk8", caption: "from today", isRedacted: false)), + .media(ChatMediaContent(blobID: blobID, width: 30, height: 20, blurhash: "LEHV6nWB2yk8", caption: nil, isRedacted: true)), + ]) + } } diff --git a/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift new file mode 100644 index 000000000..e21dcef49 --- /dev/null +++ b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift @@ -0,0 +1,53 @@ +// +// MockChatMediaBlobStore.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +@testable import FlipcashCore +@testable import Flipcash + +/// Records each blob call and lets a test choose how storing and finalization end. +@MainActor +final class MockChatMediaBlobStore: ChatMediaBlobStoring { + + static let blobID = BlobID(data: Data(repeating: 4, count: 16)) + + var policy: UploadPolicy + /// Consumed one per `storeBlob` call; once empty, every call succeeds. + var storeResults: [Result] = [] + var finalization: Result = .success(()) + var onStore: (() -> Void)? + + private(set) var storeAttempts = 0 + private(set) var storedData: [Data] = [] + private(set) var storedMimeTypes: [String] = [] + private(set) var finalizedBlobIDs: [BlobID] = [] + + init(policy: UploadPolicy = UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init(pattern: "image/*", maxSizeBytes: 5_000_000, image: .init(maxWidth: 2048, maxHeight: 2048, maxPixels: 0)), + ])) { + self.policy = policy + } + + func uploadPolicy() async throws -> UploadPolicy { + policy + } + + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { + storeAttempts += 1 + onStore?() + let result = storeResults.isEmpty ? .success(Self.blobID) : storeResults.removeFirst() + let blobID = try result.get() + storedData.append(data) + storedMimeTypes.append(mimeType) + return blobID + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + try finalization.get() + finalizedBlobIDs.append(blobID) + } +} diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index cb73629e9..30f1de532 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -16,6 +16,14 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con let text: String let repliedTo: MessageID? } + /// One `sendMediaMessage` call. + struct SentMedia: Sendable, Equatable { + let conversationID: ConversationID + let blobID: BlobID + let caption: String? + let repliedTo: MessageID? + let clientMessageID: UUID + } struct TypingCall: Sendable { let conversationID: ConversationID; let state: TypingState } /// A scripted `GetDelta` batch: one `onBatch` call with these messages + checkpoint. struct DeltaBatch: Sendable { let messages: [ConversationMessage]; let checkpoint: UInt64? } @@ -64,6 +72,8 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con private var _sendError: Error? private var _sentClientIDs: [UUID] = [] private var _sent: [Sent] = [] + private var _sentMedia: [SentMedia] = [] + private var _mediaSendError: Error? private var _edited: [Edited] = [] private var _reacted: [Reacted] = [] private var _reactionHandler: (@Sendable (Reacted) async throws -> EmojiReaction)? @@ -164,6 +174,13 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con /// The client message ids `sendMessage` was called with, in order. var sentClientIDs: [UUID] { lock.withLock { _sentClientIDs } } var sent: [Sent] { lock.withLock { _sent } } + /// Every `sendMediaMessage` call, in order, including the ones that threw. + var sentMedia: [SentMedia] { lock.withLock { _sentMedia } } + /// Thrown by `sendMediaMessage` while set. + var mediaSendError: Error? { + get { lock.withLock { _mediaSendError } } + set { lock.withLock { _mediaSendError = newValue } } + } /// The edits `editMessage` was called with, in order. var edited: [Edited] { lock.withLock { _edited } } /// The add/remove reaction calls, in order. @@ -394,6 +411,19 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con ) } + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + let (count, error) = lock.withLock { + _sentMedia.append(SentMedia(conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID)) + return (_sentMedia.count, _mediaSendError) + } + if let error { throw error } + return ConversationMessage( + id: MessageID(value: 100 + UInt64(count)), senderID: nil, + content: .media([MediaAttachment(blobID: blobID, width: 4, height: 3, blurhash: nil)], caption: caption), + date: Date(timeIntervalSince1970: 0), unreadSeq: 0, repliedTo: repliedTo + ) + } + func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation { lock.withLock { _edited.append(Edited(conversationID: conversationID, messageID: messageID, text: text, repliedTo: repliedTo, expectedEventSequence: expectedEventSequence)) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index b238004c5..82406c59b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -148,7 +148,8 @@ public final class ChatBubbleView: UIView { /// Whether this bubble is currently flashing. var isFlashingAttention: Bool { background.isFlashingAttention } - public func configure(with message: ChatMessage) { + /// Fills the bubble; `quoteThumbnailURL` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnailURL: URL? = nil) { label.attributedText = Self.displayText(for: message) editedLabel.isHidden = !Self.showsEditedMarker(for: message) || message.rendersAsLargeEmoji isBare = message.rendersAsLargeEmoji @@ -157,7 +158,7 @@ public final class ChatBubbleView: UIView { let hint: String? switch message.content { case .unavailable(let unavailableHint): hint = unavailableHint.text - case .text, .cash, .deleted, .shareProfile: hint = nil + case .text, .cash, .deleted, .shareProfile, .media: hint = nil } hintLabel.text = hint hintLabel.isHidden = hint == nil @@ -181,7 +182,7 @@ public final class ChatBubbleView: UIView { // unsatisfiable, and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote) + quotePanel.configure(with: quote, thumbnailURL: quoteThumbnailURL) NSLayoutConstraint.deactivate(quoteCollapse) quoteTrailing.isActive = true labelTopToBubble.isActive = false @@ -215,14 +216,14 @@ public final class ChatBubbleView: UIView { case .text: message.isEdited case .deleted: false case .cash: false - case .unavailable, .shareProfile: false + case .unavailable, .shareProfile, .media: false } } private static func isUnavailable(_ message: ChatMessage) -> Bool { switch message.content { case .unavailable: true - case .text, .cash, .deleted, .shareProfile: false + case .text, .cash, .deleted, .shareProfile, .media: false } } @@ -265,7 +266,7 @@ public final class ChatBubbleView: UIView { isPlaceholder = true case .unavailable: return unavailableText() - case .cash, .shareProfile: + case .cash, .shareProfile, .media: return nil } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index ff7e237c8..dfb246104 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -49,6 +49,8 @@ extension ChatItem { ChatCashCardCell.reuseIdentifier case .shareProfile: ChatShareProfileCell.reuseIdentifier + case .media: + ChatMediaCell.reuseIdentifier } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift index b424a0ca1..88d18f5f8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift @@ -95,12 +95,19 @@ public final class ChatLinkMessageCell: ChatColumnCell { reactionRow.prepareForReuse() } - /// - Parameter maxWidth: the widest the bubble may grow before its text wraps. - public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { + /// - Parameters: + /// - maxWidth: the widest the bubble may grow before its text wraps. + /// - quoteThumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + authorImageData: Data? = nil, + quoteThumbnailURL: URL? = nil + ) { bubbleMaxWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.isActive = Self.cardFillsWidth(message.linkPreview?.card) - bubble.configure(with: message) + bubble.configure(with: message, quoteThumbnailURL: quoteThumbnailURL) reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth reactionRow.hugsTrailingEdge = message.sender == .me diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift new file mode 100644 index 000000000..bb405fe2f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift @@ -0,0 +1,35 @@ +// +// ChatMediaBubbleSizing.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The size a photo bubble draws at, known from the photo's dimensions before any bytes arrive. +nonisolated enum ChatMediaBubbleSizing { + + /// The widest a photo may draw: 1:2, height over width. + static let minAspect: Double = 0.5 + /// The tallest a photo may draw: 2:1, height over width. + static let maxAspect: Double = 2.0 + + /// A bubble's footprint, and whether the photo is centre-cropped to fit it. + struct Size: Equatable { + let width: Double + let height: Double + let cropped: Bool + } + + /// The bubble for a `imageWidth` × `imageHeight` photo: always `maxWidth` wide, its height following + /// the photo's aspect clamped to 1:2…2:1. A photo missing either dimension draws square. + static func size(imageWidth: Double, imageHeight: Double, maxWidth: Double) -> Size { + guard imageWidth > 0, imageHeight > 0 else { + return Size(width: maxWidth, height: maxWidth, cropped: false) + } + let aspect = imageHeight / imageWidth + let clamped = min(max(aspect, minAspect), maxAspect) + return Size(width: maxWidth, height: maxWidth * clamped, cropped: clamped != aspect) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift new file mode 100644 index 000000000..17d39cf6c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift @@ -0,0 +1,255 @@ +// +// ChatMediaCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import FlipcashCore +import Kingfisher + +/// A recycled cell for a photo row: the photo at the transcript's bubble width and a clamped aspect, +/// its caption as a text bubble directly under it, and the reaction pills, in a `ChatColumnCell`. +/// +/// The photo draws from, in order: the local image of a pending send this device staged, the +/// resolved download URL (fading in over the BlurHash), or the BlurHash alone. A BlurHash-only row — +/// see ``isBlurhashOnly(_:canReact:)`` — draws nothing else, takes no tap, and hides its reactions. +public final class ChatMediaCell: ChatColumnCell { + + public static let reuseIdentifier = "ChatMediaCell" + + /// The gap between the photo and its caption bubble: the transcript's tight row gap, since the + /// two read as one run. + static let captionGap: CGFloat = 5 + private static let captionInset: CGFloat = 12 + private static let captionPadding: CGFloat = 9 + private static let fadeDuration: TimeInterval = 0.25 + + private let stack = UIStackView() + private let imageBubble = BubbleBackgroundView() + let imageView = UIImageView() + let captionBubble = BubbleBackgroundView() + let captionLabel = UILabel() + let reactionRow = ReactionPillRowView() + let imageTap = UITapGestureRecognizer() + + private var imageWidthConstraint: NSLayoutConstraint! + private var imageHeightConstraint: NSLayoutConstraint! + private var captionMaxWidthConstraint: NSLayoutConstraint! + private var reactionRowWidthConstraint: NSLayoutConstraint! + + /// The row the image view is currently drawing, so a pending row that confirms keeps its local + /// image under the download instead of dropping back to the BlurHash. + private var drawnRowID: String? + + /// Whether the row `configure` last ran with draws only its BlurHash. + private(set) var blurhashOnly = false + + /// Fired when the viewer taps the photo. Never fires for a BlurHash-only or a failed row. + var onImageTap: (() -> Void)? + /// Fired when the viewer taps a reaction pill — the argument is the toggled emoji. + var onReactionTap: ((String) -> Void)? + /// Fired on a long-press of a reaction pill, to open the reactors sheet scoped to that emoji. + var onReactionLongPress: ((String) -> Void)? + /// Fired when the trailing "+" is tapped, to open the picker. + var onReactionAdd: (() -> Void)? + + public override init(frame: CGRect) { + super.init(frame: frame) + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + imageBubble.addSubview(imageView) + imageBubble.isAccessibilityElement = true + imageBubble.accessibilityLabel = "Photo" + imageTap.addTarget(self, action: #selector(imageTapped)) + imageBubble.addGestureRecognizer(imageTap) + + captionLabel.font = .default(size: 16, weight: .medium) + captionLabel.textColor = .white + captionLabel.numberOfLines = 0 + captionLabel.translatesAutoresizingMaskIntoConstraints = false + captionBubble.addSubview(captionLabel) + captionBubble.isHidden = true + + stack.axis = .vertical + stack.spacing = Self.captionGap + stack.addArrangedSubview(imageBubble) + stack.addArrangedSubview(captionBubble) + + installColumn(content: stack, accessory: reactionRow) + reactionRow.onToggle = { [weak self] emoji in self?.onReactionTap?(emoji) } + reactionRow.onLongPress = { [weak self] emoji in self?.onReactionLongPress?(emoji) } + reactionRow.onAdd = { [weak self] in self?.onReactionAdd?() } + + imageWidthConstraint = imageBubble.widthAnchor.constraint(equalToConstant: 240) + // Below required so the photo yields to the cell's self-sizing height instead of fighting it. + imageHeightConstraint = imageBubble.heightAnchor.constraint(equalToConstant: 240) + imageHeightConstraint.priority = UILayoutPriority(999) + captionMaxWidthConstraint = captionBubble.widthAnchor.constraint(lessThanOrEqualToConstant: 240) + reactionRowWidthConstraint = reactionRow.widthAnchor.constraint(equalToConstant: 240) + + NSLayoutConstraint.activate([ + imageWidthConstraint, + imageHeightConstraint, + captionMaxWidthConstraint, + reactionRowWidthConstraint, + + imageView.topAnchor.constraint(equalTo: imageBubble.topAnchor), + imageView.leadingAnchor.constraint(equalTo: imageBubble.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: imageBubble.bottomAnchor), + + captionLabel.topAnchor.constraint(equalTo: captionBubble.topAnchor, constant: Self.captionPadding), + captionLabel.bottomAnchor.constraint(equalTo: captionBubble.bottomAnchor, constant: -Self.captionPadding), + captionLabel.leadingAnchor.constraint(equalTo: captionBubble.leadingAnchor, constant: Self.captionInset), + captionLabel.trailingAnchor.constraint(equalTo: captionBubble.trailingAnchor, constant: -Self.captionInset), + ]) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Scopes the row's touch target to the photo, caption, and pills, like the cash card: the cell + /// spans the full transcript width, and a tap beside the photo should not land on it. + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + stack.convert(stack.bounds, to: self).contains(point) + || (!reactionRow.isHidden && reactionRow.convert(reactionRow.bounds, to: self).contains(point)) + } + + public override func prepareForReuse() { + super.prepareForReuse() + imageView.kf.cancelDownloadTask() + imageView.image = nil + drawnRowID = nil + reactionRow.prepareForReuse() + } + + /// Whether a photo draws only its BlurHash: the server withheld it, or the viewer is previewing a + /// group they have not joined — the one reason `canReact` is false. Such a row is never fetched. + static func isBlurhashOnly(_ media: ChatMediaContent, canReact: Bool) -> Bool { + media.isRedacted || !canReact + } + + /// - Parameters: + /// - maxWidth: the transcript's widest bubble, which the photo always spans. + /// - localImage: the picked image of a pending send this device staged, or nil. + /// - remoteURL: the photo's resolved download URL, or nil until it is resolved. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + localImage: UIImage?, + remoteURL: URL?, + authorImageData: Data? = nil + ) { + guard case .media(let media) = message.content else { return } + let blurhashOnly = Self.isBlurhashOnly(media, canReact: message.canReact) + self.blurhashOnly = blurhashOnly + + let size = ChatMediaBubbleSizing.size( + imageWidth: Double(media.width), + imageHeight: Double(media.height), + maxWidth: Double(maxWidth) + ) + imageWidthConstraint.constant = size.width + imageHeightConstraint.constant = size.height + drawImage( + for: message.id, + blobID: media.blobID, + blurhash: media.blurhash, + localImage: blurhashOnly ? nil : localImage, + remoteURL: blurhashOnly ? nil : remoteURL, + size: CGSize(width: size.width, height: size.height) + ) + imageTap.isEnabled = !blurhashOnly && !message.isFailed + + let caption = media.caption.flatMap { $0.isEmpty ? nil : $0 } + captionLabel.text = caption + captionBubble.isHidden = caption == nil + captionMaxWidthConstraint.constant = maxWidth + + let isFromSelf = message.sender == .me + let fill = BubbleBackgroundView.fill(isFromSelf: isFromSelf) + imageBubble.apply( + fill: fill, + radii: BubbleBackgroundView.radii( + isFromSelf: isFromSelf, + groupedAbove: message.joinsBubbleAbove, + groupedBelow: caption != nil || message.joinsBubbleBelow + ), + identity: message.id + ) + captionBubble.apply( + fill: fill, + radii: BubbleBackgroundView.radii(isFromSelf: isFromSelf, groupedAbove: true, groupedBelow: message.joinsBubbleBelow), + identity: message.id + ) + stack.alignment = isFromSelf ? .trailing : .leading + + reactionRowWidthConstraint.constant = maxWidth + reactionRow.layoutWidth = maxWidth + reactionRow.hugsTrailingEdge = isFromSelf + reactionRow.configure(pills: blurhashOnly ? [] : message.reactions, canReact: message.canReact && !blurhashOnly) + reactionRow.isHidden = blurhashOnly + updateColumn(for: message, authorImageData: authorImageData) + } + + private func drawImage(for rowID: String, blobID: BlobID?, blurhash: String?, localImage: UIImage?, remoteURL: URL?, size: CGSize) { + let isSameRow = drawnRowID == rowID + drawnRowID = rowID + + if let localImage { + imageView.kf.cancelDownloadTask() + imageView.image = localImage + return + } + + let preview = BlurHashCache.shared.image(for: blurhash) + guard let remoteURL else { + imageView.kf.cancelDownloadTask() + imageView.image = preview + return + } + + // Whatever this row already shows — its local image, or the photo itself on a reconfigure — + // stays under the load, so only a BlurHash is ever faded over. + let placeholder = isSameRow ? (imageView.image ?? preview) : preview + imageView.kf.setImage( + with: ChatMediaImageSource.resource(blobID: blobID, url: remoteURL), + placeholder: placeholder, + options: [ + .processor(DownsamplingImageProcessor(size: size)), + .scaleFactor(traitCollection.displayScale), + .transition(.fade(Self.fadeDuration)), + ] + ) + } + + @objc private func imageTapped() { + onImageTap?() + } +} + +extension ChatMediaCell: BubbleCarrying { + /// The photo and its caption, not the cell: the cell spans the full row. + var liftPreviewView: UIView { stack } + + var liftPreviewMaskingPath: UIBezierPath? { + let path = UIBezierPath() + for bubble in [imageBubble, captionBubble] where !bubble.isHidden { + let shape = bubble.maskingPath + shape.apply(CGAffineTransform(translationX: bubble.frame.minX, y: bubble.frame.minY)) + path.append(shape) + } + return path + } + + func flashAttention(startedAt start: CFTimeInterval) { + imageBubble.flashAttention(startedAt: start) + if !captionBubble.isHidden { captionBubble.flashAttention(startedAt: start) } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift new file mode 100644 index 000000000..b18a3e5fc --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift @@ -0,0 +1,20 @@ +// +// ChatMediaImageSource.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import Kingfisher + +/// Where a chat photo's bytes load from, for every view that draws one. +public enum ChatMediaImageSource { + + /// The photo at `url`, cached under its blob rather than the URL. The URL is signed and minted + /// per fetch, so keying on it would re-download the same photo every session. + public static func resource(blobID: BlobID?, url: URL) -> KF.ImageResource { + KF.ImageResource(downloadURL: url, cacheKey: blobID.map { "chat-media-\($0.description)" } ?? url.absoluteString) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift new file mode 100644 index 000000000..0815161d8 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift @@ -0,0 +1,101 @@ +// +// ChatMediaURLResolver.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// Resolves and caches the signed download URL for each photo in one conversation. +/// +/// It is the only path from a photo row to the network, so a row that draws only its BlurHash — a +/// redacted photo, or any photo while the viewer previews a group they have not joined — never +/// fetches. Not fetching is what keeps that state from being a blur over bytes already on the device. +public final class ChatMediaURLResolver { + + /// Mints a download URL for a blob, or returns nil when the server has none to give. + public typealias Fetch = @MainActor (BlobID) async throws -> URL? + + private let fetch: Fetch + private var cache: [BlobID: URL] = [:] + /// Everyone waiting on a blob's fetch, called with its URL, or nil when the fetch failed. + private var waiters: [BlobID: [(URL?) -> Void]] = [:] + + public init(fetch: @escaping Fetch) { + self.fetch = fetch + } + + /// The URL to draw `media` from, or nil for now. + /// + /// A BlurHash-only row returns nil without fetching. A cache miss starts at most one fetch per + /// blob and calls `onResolved` when it lands so the caller can redraw the row; a failed fetch + /// leaves the row on its BlurHash, and the next call tries again. + public func url( + for media: ChatMediaContent, + canReact: Bool, + onResolved: @escaping (URL) -> Void + ) -> URL? { + guard !ChatMediaCell.isBlurhashOnly(media, canReact: canReact), + let blobID = media.blobID else { return nil } + return resolve(blobID) { $0.map(onResolved) } + } + + /// The URL to draw a quoted photo's thumbnail from, or nil for now, sharing the photo row's cache. + /// + /// Nil without fetching for anything but a photo whose bytes the viewer may see: a quote of text, + /// cash, or an unavailable original, a redacted photo (``ChatQuote/Kind/media(thumbnailBlobID:)`` + /// carries no blob), or any quote while the viewer previews a group they have not joined. + public func thumbnailURL( + for kind: ChatQuote.Kind, + canReact: Bool, + onResolved: @escaping (URL) -> Void + ) -> URL? { + guard let blobID = Self.thumbnailBlobID(for: kind, canReact: canReact) else { return nil } + return resolve(blobID) { $0.map(onResolved) } + } + + /// The URL to draw a quoted photo's thumbnail from, waiting for the fetch; nil when there is none + /// to draw or the fetch failed. Follows the same rules as the callback form. + public func thumbnailURL(for kind: ChatQuote.Kind, canReact: Bool) async -> URL? { + guard let blobID = Self.thumbnailBlobID(for: kind, canReact: canReact) else { return nil } + return await withCheckedContinuation { continuation in + var resumed = false + let immediate = resolve(blobID) { url in + guard !resumed else { return } + resumed = true + continuation.resume(returning: url) + } + if let immediate, !resumed { + resumed = true + continuation.resume(returning: immediate) + } + } + } + + private static func thumbnailBlobID(for kind: ChatQuote.Kind, canReact: Bool) -> BlobID? { + switch kind { + case .media(let thumbnailBlobID): + canReact ? thumbnailBlobID : nil + case .text, .cash, .unavailable: + nil + } + } + + /// The cached URL for `blobID`, or nil after queueing `completion` on the one fetch for it. + private func resolve(_ blobID: BlobID, completion: @escaping (URL?) -> Void) -> URL? { + if let cached = cache[blobID] { return cached } + let isFirst = waiters[blobID] == nil + waiters[blobID, default: []].append(completion) + guard isFirst else { return nil } + Task { + // Best-effort: the row keeps its BlurHash, and the next dequeue retries. + let resolved = try? await fetch(blobID) + if let resolved { cache[blobID] = resolved } + let completions = waiters.removeValue(forKey: blobID) ?? [] + for completion in completions { completion(resolved) } + } + return nil + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift new file mode 100644 index 000000000..07ccf6f8a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift @@ -0,0 +1,211 @@ +// +// ChatMediaViewerController.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import FlipcashCore +import Kingfisher + +/// What the transcript hands its owner when a photo is tapped: the photo to show and the view it +/// zooms out of. +public struct ChatMediaViewerRequest { + + /// The tapped row's stable id. + public let messageID: String + /// The photo's blob, which keys its download in the image cache. + public let blobID: BlobID? + /// The staged image of a send this device has not confirmed yet, drawn instead of downloading. + public let localImage: UIImage? + /// The photo's resolved download URL, or nil for a pending send. + public let remoteURL: URL? + /// Whatever the row drew when it was tapped, shown while the full photo loads. + public let placeholder: UIImage? + /// The on-screen view the photo zooms out of and back into, looked up on each use because the + /// row's cell can be recycled while the viewer is up. + public let sourceView: () -> UIView? + + /// Nil when the row may not open: not a photo, BlurHash-only (see + /// ``ChatMediaCell/isBlurhashOnly(_:canReact:)``), a failed send whose tap is the retry, or a + /// photo with neither a staged image nor a resolved URL yet. + public init?( + message: ChatMessage, + localImage: UIImage?, + remoteURL: URL?, + placeholder: UIImage?, + sourceView: @escaping () -> UIView? + ) { + guard case .media(let media) = message.content, + !ChatMediaCell.isBlurhashOnly(media, canReact: message.canReact), + !message.isFailed, + localImage != nil || remoteURL != nil else { return nil } + self.messageID = message.id + self.blobID = media.blobID + self.localImage = localImage + self.remoteURL = remoteURL + self.placeholder = placeholder + self.sourceView = sourceView + } +} + +/// A full-screen photo, zoomed in from the tapped transcript row: pinch or double-tap to zoom, swipe +/// down or close to put it back, and share once the photo itself has loaded. +public final class ChatMediaViewerController: UIViewController, UIScrollViewDelegate { + + private static let maximumZoomScale: CGFloat = 4 + private static let doubleTapZoomScale: CGFloat = 2.5 + + private let request: ChatMediaViewerRequest + private let onShare: (UIImage) -> Void + + let scrollView = UIScrollView() + let imageView = UIImageView() + let shareButton = UIButton(configuration: .plain()) + private let closeButton = UIButton(configuration: .plain()) + + /// The photo itself, once drawn — never the placeholder, so share never hands out a BlurHash. + private var loadedImage: UIImage? { + didSet { shareButton.isEnabled = loadedImage != nil } + } + + /// - Parameter onShare: presents the system share sheet for the photo. + public init(request: ChatMediaViewerRequest, onShare: @escaping (UIImage) -> Void) { + self.request = request + self.onShare = onShare + super.init(nibName: nil, bundle: nil) + + let zoom = UIViewController.Transition.ZoomOptions() + // A zoomed-in photo pans instead; the swipe down only dismisses at the fitted size. + zoom.interactiveDismissShouldBegin = { [weak self] _ in + guard let self else { return true } + return scrollView.zoomScale <= scrollView.minimumZoomScale + } + preferredTransition = .zoom(options: zoom) { [sourceView = request.sourceView] _ in sourceView() } + modalPresentationStyle = .fullScreen + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + scrollView.delegate = self + scrollView.minimumZoomScale = 1 + scrollView.maximumZoomScale = Self.maximumZoomScale + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + + imageView.contentMode = .scaleAspectFit + imageView.isAccessibilityElement = true + imageView.accessibilityLabel = "Photo" + imageView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(imageView) + + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTapped(_:))) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + + configure(closeButton, symbol: "xmark", label: "Close", action: #selector(closeTapped)) + configure(shareButton, symbol: "square.and.arrow.up", label: "Share", action: #selector(shareTapped)) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + // Pinned to the frame, so at scale 1 the photo fits the screen and zooming grows it. + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + closeButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 8), + shareButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + shareButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -8), + ]) + + loadImage() + } + + private func configure(_ button: UIButton, symbol: String, label: String, action: Selector) { + button.configuration?.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) + button.configuration?.baseForegroundColor = .white + button.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + button.accessibilityLabel = label + button.addTarget(self, action: action, for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(button) + } + + /// Draws the staged image, or the placeholder under the download. The transcript caches only its + /// downsampled copy, so the full-size photo downloads here, over the copy the cell already drew. + private func loadImage() { + if let localImage = request.localImage { + imageView.image = localImage + loadedImage = localImage + return + } + imageView.image = request.placeholder + loadedImage = nil + guard let remoteURL = request.remoteURL else { return } + imageView.kf.setImage( + with: ChatMediaImageSource.resource(blobID: request.blobID, url: remoteURL), + placeholder: request.placeholder + ) { [weak self] result in + switch result { + case .success(let value): + self?.loadedImage = value.image + case .failure: + // Best-effort: the placeholder stays up and share stays off. + break + } + } + } + + // MARK: - Zoom + + /// The scale a double-tap lands on from `current`: in to the double-tap scale from the fitted + /// size, and back out to fitted from anywhere else. + static func doubleTapTargetScale(_ current: CGFloat, _ minimum: CGFloat) -> CGFloat { + current > minimum ? minimum : doubleTapZoomScale + } + + public func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) { + let target = Self.doubleTapTargetScale(scrollView.zoomScale, scrollView.minimumZoomScale) + guard target > scrollView.minimumZoomScale else { + scrollView.setZoomScale(target, animated: true) + return + } + let point = recognizer.location(in: imageView) + let size = CGSize(width: scrollView.bounds.width / target, height: scrollView.bounds.height / target) + scrollView.zoom(to: CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height), animated: true) + } + + // MARK: - Actions + + @objc private func closeTapped() { + dismiss(animated: true) + } + + @objc private func shareTapped() { + guard let loadedImage else { return } + onShare(loadedImage) + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift index 5906384d4..3ac34f097 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift @@ -63,10 +63,17 @@ public final class ChatMessageCell: ChatColumnCell { reactionRow.prepareForReuse() } - /// - Parameter maxWidth: the widest the bubble may grow before its text wraps, in points. - /// The owner derives it from the collection view's width. - public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { - bubble.configure(with: message) + /// - Parameters: + /// - maxWidth: the widest the bubble may grow before its text wraps, in points. The owner + /// derives it from the collection view's width. + /// - quoteThumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + authorImageData: Data? = nil, + quoteThumbnailURL: URL? = nil + ) { + bubble.configure(with: message, quoteThumbnailURL: quoteThumbnailURL) maxWidthConstraint.constant = maxWidth reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 74b193da5..87235c503 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -8,6 +8,7 @@ #if canImport(UIKit) import UIKit import FlipcashCore +import Kingfisher /// The quoted original drawn inside a reply's bubble, above the body: a leading rule, the author, /// and up to two lines of the original. Tapping it asks to jump to that message — but only when @@ -32,6 +33,17 @@ final class ChatQuotePanelView: UIView { /// Takes the slack so the flag, amount and token stay clustered at the leading edge instead of /// the amount stretching and pushing the token to the far side of the panel. private let detailSpacer = UIView() + /// Drawn only for a quoted photo the viewer may see: the photo itself at the trailing edge. + private(set) var thumbnailView = UIImageView() + /// The text's trailing edge with no thumbnail, and with one; exactly one is active. + private lazy var textTrailingToEdge = authorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8) + private lazy var textTrailingToThumbnail = authorLabel.trailingAnchor.constraint(equalTo: thumbnailView.leadingAnchor, constant: -8) + private lazy var thumbnailConstraints = [ + thumbnailView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + thumbnailView.centerYAnchor.constraint(equalTo: centerYAnchor), + thumbnailView.widthAnchor.constraint(equalToConstant: Self.thumbnailSide), + thumbnailView.heightAnchor.constraint(equalToConstant: Self.thumbnailSide), + ] /// The gap between the panel and the bubble's edges, the same on the top, leading and trailing /// sides — the bubble's own vertical margin. One gap rather than three, so ``cornerRadius`` @@ -52,6 +64,9 @@ final class ChatQuotePanelView: UIView { /// rather than as a second element the line has to make room for. private static let flagDiameter: CGFloat = 14 + /// The two text lines' height, so the thumbnail sits in the panel without growing it. + private static let thumbnailSide: CGFloat = 34 + /// The preview grey a quoted sentence is drawn in. private static let snippetColor = UIColor.white.withAlphaComponent(0.55) @@ -108,6 +123,16 @@ final class ChatQuotePanelView: UIView { detailRow.addArrangedSubview(detailSpacer) addSubview(detailRow) + thumbnailView.contentMode = .scaleAspectFill + thumbnailView.clipsToBounds = true + thumbnailView.layer.cornerRadius = 6 + thumbnailView.layer.cornerCurve = .continuous + // The ground a photo loads onto, so the slot reads as a photo before any bytes arrive. + thumbnailView.backgroundColor = UIColor.white.withAlphaComponent(0.08) + thumbnailView.isHidden = true + thumbnailView.translatesAutoresizingMaskIntoConstraints = false + addSubview(thumbnailView) + // The rule, the two gutters around the text and the detail row's spacing add up to a width // the panel demands even when it holds nothing, and a bubble with no quote would pay for it: // the host pins the panel to both of the bubble's sides, so the panel's floor becomes the @@ -116,13 +141,13 @@ final class ChatQuotePanelView: UIView { let horizontal = [ rule.widthAnchor.constraint(equalToConstant: Self.ruleWidth), authorLabel.leadingAnchor.constraint(equalTo: rule.trailingAnchor, constant: 8), - authorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), detailRow.leadingAnchor.constraint(equalTo: authorLabel.leadingAnchor), detailRow.trailingAnchor.constraint(equalTo: authorLabel.trailingAnchor), ] - for constraint in horizontal { + for constraint in horizontal + [textTrailingToEdge, textTrailingToThumbnail] + thumbnailConstraints { constraint.priority = .required - 1 } + textTrailingToEdge.isActive = true NSLayoutConstraint.activate(horizontal + [ // Flush against the cell's leading edge and the full height of it, so the cell reads as @@ -146,7 +171,8 @@ final class ChatQuotePanelView: UIView { accessibilityIdentifier = "chat-quote-panel" } - func configure(with quote: ChatQuote) { + /// - Parameter thumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + func configure(with quote: ChatQuote, thumbnailURL: URL? = nil) { targetStableID = quote.stableID // The author's own colour, derived from their user id — the same colour the composer's strip // draws them in, and the same one Android does. An original with no known author falls back @@ -170,10 +196,17 @@ final class ChatQuotePanelView: UIView { // A payment's amount is the whole of what was said, so it is read rather than glanced // at — a step brighter than the preview grey a quoted sentence gets. snippetLabel.textColor = UIColor.white.withAlphaComponent(0.75) - case .text, .media, .unavailable: + showThumbnail(blobID: nil, url: nil) + case .media(let thumbnailBlobID): + flagView.isHidden = true + tokenLabel.isHidden = true + snippetLabel.textColor = Self.snippetColor + showThumbnail(blobID: thumbnailBlobID, url: thumbnailURL) + case .text, .unavailable: flagView.isHidden = true tokenLabel.isHidden = true snippetLabel.textColor = Self.snippetColor + showThumbnail(blobID: nil, url: nil) } let spoken = switch quote.kind { case .cash(let token, _): "\(quote.snippet) \(token)" @@ -197,10 +230,40 @@ final class ChatQuotePanelView: UIView { flagView.image = nil flagView.isHidden = true tokenLabel.isHidden = true + showThumbnail(blobID: nil, url: nil) isUserInteractionEnabled = false accessibilityLabel = nil } + /// Shows the thumbnail slot for a photo with a blob, loading it once `url` resolves; hides and + /// empties it otherwise. A redacted photo has no blob, so it never reaches the network from here. + private func showThumbnail(blobID: BlobID?, url: URL?) { + guard let blobID else { + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + thumbnailView.isHidden = true + NSLayoutConstraint.deactivate(thumbnailConstraints + [textTrailingToThumbnail]) + textTrailingToEdge.isActive = true + return + } + thumbnailView.isHidden = false + textTrailingToEdge.isActive = false + NSLayoutConstraint.activate(thumbnailConstraints + [textTrailingToThumbnail]) + guard let url else { + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + return + } + let side = Self.thumbnailSide + thumbnailView.kf.setImage( + with: ChatMediaImageSource.resource(blobID: blobID, url: url), + options: [ + .processor(DownsamplingImageProcessor(size: CGSize(width: side, height: side))), + .scaleFactor(traitCollection.displayScale), + ] + ) + } + @objc private func handleTap() { guard let targetStableID else { return } onTap?(targetStableID) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index a7565d986..16c4ec1ec 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -185,6 +185,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onLinkCardTap = newValue } } + /// Fired when a photo in the transcript is tapped — see ``ChatViewController/onMediaTap``. + public var onMediaTap: ((ChatMediaViewerRequest) -> Void)? { + get { transcript.onMediaTap } + set { transcript.onMediaTap = newValue } + } + /// Where a link card in the transcript looks its link up — see /// ``ChatViewController/linkCardSource``. public weak var linkCardSource: (any LinkCardSource)? { @@ -192,6 +198,18 @@ public final class ChatScreenViewController: UIViewController { set { transcript.linkCardSource = newValue } } + /// Where a photo row gets its download URL — see ``ChatViewController/mediaURLResolver``. + public var mediaURLResolver: ChatMediaURLResolver? { + get { transcript.mediaURLResolver } + set { transcript.mediaURLResolver = newValue } + } + + /// A pending photo's local image — see ``ChatViewController/pendingMediaImage``. + public var pendingMediaImage: ((String) -> UIImage?)? { + get { transcript.pendingMediaImage } + set { transcript.pendingMediaImage = newValue } + } + public var onContactAction: (() -> Void)? { get { transcript.onContactAction } set { transcript.onContactAction = newValue } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 7cfe38c5c..010afb4e3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -118,6 +118,16 @@ public final class ChatViewController: UICollectionViewController { /// Fired when a message row's trailing "+" reaction pill is tapped, to open the picker for that /// row's stable id. public var onReactionAdd: ((String) -> Void)? + /// Fired when a photo row's image is tapped, to open the full-screen viewer. Never fires for a + /// BlurHash-only or failed row, or one with nothing to show yet. + public var onMediaTap: ((ChatMediaViewerRequest) -> Void)? + + /// Where a photo row gets its download URL. Nil draws every photo from its BlurHash. + public var mediaURLResolver: ChatMediaURLResolver? + + /// The local image of a photo this device is still sending, by the pending row's id; the row draws + /// it until the send confirms and the download takes over. + public var pendingMediaImage: ((String) -> UIImage?)? /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 @@ -325,6 +335,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatLinkMessageCell.self, forCellWithReuseIdentifier: ChatLinkMessageCell.reuseIdentifier) collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) collectionView.register(ChatShareProfileCell.self, forCellWithReuseIdentifier: ChatShareProfileCell.reuseIdentifier) + collectionView.register(ChatMediaCell.self, forCellWithReuseIdentifier: ChatMediaCell.reuseIdentifier) collectionView.register(ChatDateSeparatorCell.self, forCellWithReuseIdentifier: ChatDateSeparatorCell.reuseIdentifier) collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) collectionView.register(ChatEncryptionMarkerCell.self, forCellWithReuseIdentifier: ChatEncryptionMarkerCell.reuseIdentifier) @@ -637,12 +648,17 @@ public final class ChatViewController: UICollectionViewController { let authorImageData = message.author.flatMap { authorAvatars[$0.id] } (cell as? ChatColumnCell)?.onAuthorTap = { [weak self] userID in self?.onAuthorTap?(userID) } switch cell { - // Only text messages are sent optimistically, so only they can reach the failed state - // that arms retry (wired on both text cells). Cash messages are always server-confirmed. + // Text and photo messages are sent optimistically, so only they can reach the failed state + // that arms retry. Cash messages are always server-confirmed. case let cell as ChatLinkMessageCell: // Before `configure`, which is where the card subscribes. cell.linkCardSource = linkCardSource - cell.configure(with: message, maxWidth: maxWidth, authorImageData: authorImageData) + cell.configure( + with: message, + maxWidth: maxWidth, + authorImageData: authorImageData, + quoteThumbnailURL: quoteThumbnailURL(for: message) + ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onOpenURL = { [weak self] url in self?.onOpenURL?(url) } cell.onMentionTap = { [weak self] username in self?.onMentionTap?(username) } @@ -652,7 +668,12 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } case let cell as ChatMessageCell: - cell.configure(with: message, maxWidth: maxWidth, authorImageData: authorImageData) + cell.configure( + with: message, + maxWidth: maxWidth, + authorImageData: authorImageData, + quoteThumbnailURL: quoteThumbnailURL(for: message) + ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onQuoteTap = { [weak self] id in self?.onQuoteTap?(id) } cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } @@ -673,11 +694,67 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } + case let cell as ChatMediaCell: + guard case .media(let media) = message.content else { return } + let remoteURL = mediaURLResolver?.url(for: media, canReact: message.canReact) { [weak self] _ in + self?.reconfigureVisibleMessage(id: message.id) + } + let localImage = pendingMediaImage?(message.id) + cell.configure( + with: message, + maxWidth: maxWidth, + localImage: localImage, + remoteURL: remoteURL, + authorImageData: authorImageData + ) + cell.onImageTap = { [weak self, weak cell] in + guard let self, let request = ChatMediaViewerRequest( + message: message, + localImage: localImage, + remoteURL: remoteURL, + placeholder: cell?.imageView.image, + sourceView: { [weak self] in self?.mediaSourceView(forMessageID: message.id) } + ) else { return } + onMediaTap?(request) + } + cell.onRetry = { [weak self] id in self?.onRetry?(id) } + cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } + cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } + cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } default: assertionFailure("Unhandled chat cell class for message row") } } + /// Where a reply's quoted photo draws its thumbnail from, or nil when it has none or is not yet + /// resolved; a resolution redraws the reply. + private func quoteThumbnailURL(for message: ChatMessage) -> URL? { + guard let quote = message.quote else { return nil } + return mediaURLResolver?.thumbnailURL(for: quote.kind, canReact: message.canReact) { [weak self] _ in + self?.reconfigureVisibleMessage(id: message.id) + } + } + + /// Re-runs `configure` on the on-screen row for `id`, if there is one. A row off screen picks the + /// change up on its next dequeue. + private func reconfigureVisibleMessage(id: String) { + for cell in collectionView.visibleCells { + guard let indexPath = collectionView.indexPath(for: cell), + items.indices.contains(indexPath.item), + case .message(let message) = items[indexPath.item], + message.id == id else { continue } + configure(cell, with: message) + } + } + + /// The on-screen photo of the row for `id`, or nil when that row is scrolled away. + private func mediaSourceView(forMessageID id: String) -> UIView? { + guard let index = items.firstIndex(where: { item in + if case .message(let message) = item { message.id == id } else { false } + }) else { return nil } + return (collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? ChatMediaCell)?.imageView + } + // MARK: - Selection /// Only cash cards are tappable — they open the token's currency info. Text bubbles and date @@ -768,7 +845,7 @@ public final class ChatViewController: UICollectionViewController { let cell = collectionView.cellForItem(at: indexPath) as? BubbleCarrying else { return nil } switch message.content { case .text: break - case .cash, .deleted, .unavailable, .shareProfile: return nil + case .cash, .deleted, .unavailable, .shareProfile, .media: return nil } let bubble = cell.liftPreviewView guard bubble.bounds.contains(bubble.convert(point, from: collectionView)) else { return nil } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index b7beb45e0..4d21ed0ea 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -248,7 +248,8 @@ public final class LinkableBubbleView: UIView { cardView.prepareForReuse() } - public func configure(with message: ChatMessage) { + /// Fills the bubble; `quoteThumbnailURL` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnailURL: URL? = nil) { // Shares the plain bubble's text builder so a link message gets the same body styling, the // same tombstone copy, and the same "Edited" reservation, with the link spans laid over it // from the preview the mapper already detected. @@ -295,7 +296,7 @@ public final class LinkableBubbleView: UIView { // and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote) + quotePanel.configure(with: quote, thumbnailURL: quoteThumbnailURL) NSLayoutConstraint.deactivate(quoteCollapse + [textTopToBubble, cardTopToBubble]) quoteTrailing.isActive = true textTopToQuote.isActive = !bare diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index c57a9f4a1..7be60b3f8 100644 --- a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -61,11 +61,11 @@ final class BlockUnblockSmokeTests: BaseUITestCase { row.tap() // MARK: Open the counterpart's profile from the conversation. - // The tip DM shows Send Cash — wait for it so the transcript has loaded - // before reaching for the title. + // The tip DM already exists, so its bar leads with the attach menu — wait + // for it so the transcript has loaded before reaching for the title. XCTAssertTrue( - app.buttons["send-cash-button"].waitForExistence(timeout: 30), - "Expected the tip DM conversation to open with Send Cash" + app.buttons["attach-menu-button"].waitForExistence(timeout: 30), + "Expected the tip DM conversation to open with the attach menu" ) // The nav-title item is a button (label = the name) only when blocking is // enabled and the tip counterpart resolves — both hold here. diff --git a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift index 809c7cf1f..511ed8bc0 100644 --- a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift +++ b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift @@ -18,7 +18,10 @@ struct ConversationUIScreen { // MARK: - Elements + /// The full-width Send Cash call to action, shown only before the chat exists. var sendCashButton: XCUIElement { app.buttons["send-cash-button"] } + /// The composer's `+`, which holds Cash once the chat exists. + var attachMenuButton: XCUIElement { app.buttons["attach-menu-button"] } /// The composer's text field, found by identifier rather than by placeholder — the placeholder /// disappears as soon as there is a draft, and a multiline `TextField(axis:)` reports a text-view /// base type, so neither `textFields["Message"]` nor a type-scoped query survives typing. @@ -75,9 +78,15 @@ struct ConversationUIScreen { // MARK: - Actions - /// Opens the Send amount sheet on top of the conversation. + /// Opens the Send amount sheet on top of the conversation: from the call to action before the + /// chat exists, and from the attach menu's Cash row after. func tapSendCash(from testCase: BaseUITestCase) { - testCase.waitAndTap(sendCashButton) + if attachMenuButton.exists { + testCase.waitAndTap(attachMenuButton) + testCase.waitAndTap(app.buttons["Cash"].firstMatch) + } else { + testCase.waitAndTap(sendCashButton) + } } /// Focuses the always-visible composer, types `text`, and sends it. diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index 956e204c4..7cd916360 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -87,6 +87,9 @@ private struct NotificationMessageRow: View { case .shareProfile(let profile): // The extension has no profile lookup; the handle is what the widget names. NotificationTextBubble(text: profile.linkedHandle ?? "Shared a profile", isFromSelf: message.sender == .me) + case .media(let media): + // The extension draws no photos; a text line stands in, like the chat list's preview. + NotificationTextBubble(text: ChatMediaStrings.listPreview(caption: media.caption), isFromSelf: message.sender == .me) } if message.sender == .other { Spacer(minLength: 44) } } From d33ff155ce5d1146f7f61bb831a8b23b9f418b65 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 18:06:16 -0400 Subject: [PATCH 2/2] feat(chat): open the attach menu and photo cards over the keyboard The attach menu grows out of +, and the menu, camera card and photo card are one surface that morphs between states. The surface draws over the live keyboard by joining the keyboard's window, falling back to an input view swap and then to dismissing the keyboard. The camera and picker start while the menu is up, so a card has content when it opens. A tap anywhere outside the open menu only closes it. Staged photos sit inside the message field and fade at its edges. The keyboard overlay decisions are recorded in .claude/plans/2026-10-02-attach-photo-card.md. --- .claude/plans/2026-10-02-attach-photo-card.md | 144 +++++++ .../Screens/Conversation/AttachCard.swift | 159 ++++++++ .../Conversation/AttachKeyboardOverlay.swift | 364 ++++++++++++++++++ .../Screens/Conversation/AttachMenu.swift | 196 +++++++--- .../Conversation/AttachOverKeyboard.swift | 129 +++++++ .../Screens/Conversation/AttachPanel.swift | 117 ++++++ .../Screens/Conversation/AttachSurface.swift | 298 ++++++++++++++ .../Screens/Conversation/AttachWarmUp.swift | 121 ++++++ .../Conversation/ChatCameraSheet.swift | 216 ++++++++--- .../Screens/Conversation/ChatCameraSlot.swift | 72 ---- .../Conversation/ChatPhotoCamera.swift | 164 ++++++-- .../Conversation/ChatPhotoPreloader.swift | 67 ++++ .../Conversation/ChatPhotoStaging.swift | 53 ++- .../Screens/Conversation/ChatPhotosCard.swift | 184 +++++++++ .../ChatScreenRepresentable.swift | 122 +++++- .../Screens/Conversation/ComposerChip.swift | 6 +- .../Conversation/ComposerChipStrip.swift | 61 ++- .../Screens/Conversation/ComposerModel.swift | 9 +- .../Conversation/ConversationBottomBar.swift | 317 +++++++++++++-- .../Conversation/ConversationScreen.swift | 35 +- .../Conversation/KeyboardOverlayHost.swift | 49 +++ .../Chat/AttachOverlayModeTests.swift | 179 +++++++++ .../Chat/AttachPanelPlacementTests.swift | 233 +++++++++++ FlipcashTests/Chat/AttachPanelTests.swift | 282 ++++++++++++++ FlipcashTests/Chat/AttachWarmUpTests.swift | 81 ++++ FlipcashTests/Chat/ChatCameraSheetTests.swift | 31 ++ .../Chat/ChatPhotoStagingTests.swift | 284 ++++++++++++-- FlipcashTests/Chat/ChatPhotosCardTests.swift | 29 ++ .../Chat/ComposerChipLayoutTests.swift | 79 ++++ .../ConversationControllerTests.swift | 37 ++ .../Sources/FlipcashUI/Chat/ChatMotion.swift | 25 ++ .../Chat/ChatScreenViewController.swift | 110 +++++- .../Views/Containers/GlassBackground.swift | 12 + 33 files changed, 3954 insertions(+), 311 deletions(-) create mode 100644 .claude/plans/2026-10-02-attach-photo-card.md create mode 100644 Flipcash/Core/Screens/Conversation/AttachCard.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachPanel.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachSurface.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachWarmUp.swift delete mode 100644 Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift create mode 100644 Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift create mode 100644 FlipcashTests/Chat/AttachOverlayModeTests.swift create mode 100644 FlipcashTests/Chat/AttachPanelPlacementTests.swift create mode 100644 FlipcashTests/Chat/AttachPanelTests.swift create mode 100644 FlipcashTests/Chat/AttachWarmUpTests.swift create mode 100644 FlipcashTests/Chat/ChatPhotosCardTests.swift create mode 100644 FlipcashTests/Chat/ComposerChipLayoutTests.swift diff --git a/.claude/plans/2026-10-02-attach-photo-card.md b/.claude/plans/2026-10-02-attach-photo-card.md new file mode 100644 index 000000000..6be0b5e3b --- /dev/null +++ b/.claude/plans/2026-10-02-attach-photo-card.md @@ -0,0 +1,144 @@ +# Attach flow: inline photo card and Add → chip shrink + +Target: a screen recording of ChatGPT's iOS attach flow (2026-10-02). A second pass the same day +(`6413eefd`, `1da4c9a6`, `ea099222`) matched the card geometry, panel anchoring, back-to-menu, +camera controls, and All Photos to the recording. + +## Decisions + +- **With the keyboard up, the panel and cards draw over the keys; the composer keeps focus.** An + earlier note here said the keyboard is out of process and nothing can draw over it. That was wrong: + the spike's public window was clamped to 10,000,000 (the cap on app window levels), one below the + keyboard's. The keyboard is in-process, in a `UIRemoteKeyboardWindow` at level 10,000,001 inside a + private keyboard scene, left out of `UIApplication.windows` and every scene's `windows`. A second + spike (iOS 27.2 device, iOS 26.6 device, iOS 27.0 simulator) reached it through the private + `+[UIWindow allWindowsIncludingInternalWindows:onlyVisibleWindows:]`: a subview there draws over + the keys, a `UIGlassEffect` blurs them, and the field stays first responder. A full-screen + container there whose `hitTest` returns nil outside one rect passed a real-touch probe (iOS 27.0 + simulator, 2026-10-02): the rect takes taps above and over the keys, the app window takes taps + outside it with focus kept, and uncovered keys still type. `keyboardWindow.convert(_:from: + appWindow)` returns an infinite rect, since the windows are in different scenes; convert through + the screen's coordinate space. + - **Mechanism.** `KeyboardOverlayHost` is the only code touching private API: it resolves the + selector with `NSSelectorFromString` after `responds(to:)`, matches the window by a class-name + fragment, and returns nil on any miss. `AttachKeyboardOverlay` hosts the same `AttachSurface` + the bar uses in a full-screen `AttachOverlayContainer` in that window, in the app window's + coordinates (both windows sit at the screen origin), taking touches only on the panel and card + frames. The menu is centred on the composer's bottom edge so its lower half sits over the keys + (sheet_02, `.straddlesPlus`), growing out of `+`; cards stand on the screen's bottom. + - **Rebuilds and teardown.** UIKit rebuilds the keyboard tree on focus or keyboard-type changes. + The container re-finds its window on `didMoveToWindow(nil)` and on keyboard did-show / + did-change-frame. It comes down when nothing is left in it, and at once on keyboard will-hide, + focus loss, or backgrounding. The app is portrait-only, so rotation needs nothing. + - **Cross-window hand-off.** The landing chip lives in the app window, so no shared namespace. On + Add/capture the chip is staged hidden, reports its window frame, and the card shrinks onto a + clear stand-in at that frame in the overlay's namespace; then the chip fades in. All Photos + can't present from the keyboard's window, so it hands the card to the keyboard-down flow, which + opens the full picker. + - **Risk.** Private API: Apple can rename the class or drop the selector in any release, and App + Review may flag the selector string (it's assembled from parts, never one literal). Every miss + falls back, never traps. + - **Fallback.** `AttachOverlayMode.select` picks at runtime and logs the mode: (a) keyboard window; + (b) a `UIInputView(.keyboard)` of the keyboard's height swapped in as the field's `inputView`, + with the overlay laid in that input view's window — the swap is not animated by the system and a + live inputView ignores height changes, so it matches the height exactly; (c) take the keyboard + down first, as before. A keyboard shorter than 150pt (hardware keyboard's shortcut bar) goes to + (c). `AttachCard.closesOnKeyboardShow` is off while over the keyboard, since the (b) swap posts + keyboard-show. `begin()` turns it off before the swap, so the gate holds by construction rather + than because no card happens to be open yet. +- **Cards are a fixed-proportion overlay, not a keyboard-height slot.** `AttachCard` (was + `ChatKeyboardSlot`) sizes the card to 58% of the screen height when it opens. It is an `.overlay` + on the composer row, bottom-aligned to the row's bottom edge, out to the keyboard-up margins + (`-compactExtraInset`). The bar's measured height stays the composer's, so the transcript doesn't + move. The overlay is attached after the row's `opacity`/`allowsHitTesting`, or the card would + inherit them. +- **The card rides the bar-overflow path with a touch band.** `ConversationBarModel.overflow` is + `.none`, `.everywhere` (panel: every touch above the bar goes to the bar, so an outside tap + dismisses), or `.band(top:)` (card: touches from the card's window-space top edge down). + `BarOverflowReporting` hands it to `ChatScreenViewController.barOverflowTouchTop` / + `barOverflowsTop`; `BarClipView.point(inside:)` limits the overflow region to the band. The card's + top is measured with `onGeometryChange(.global)`, not computed from bar padding. + `AttachPanelPlacementTests` uses the same modifier. +- **One surface, not a panel and a card.** (2026-10-03, after a device recording showed a + card-sized glass rectangle with the menu rows fading over it on Back: the panel and the card were + two views joined by `matchedGeometryEffect`, and both were on screen mid-morph.) `AttachSurface` + draws one glass shape. `AttachSurfaceFrame` animates its rect and corner radius as one + `Animatable` value, so the clip and the glass can't drift apart. The phase + (`ConversationBarModel.attachSurfacePhase`: collapsed / menu / card / landing) picks the shape + through `AttachSurfaceLayout.shape(for:…)`; the host passes `+`'s, the card's, and the landing + chip's rects in its own coordinates. The bar and the keyboard overlay draw the same view, with + `.standsOnPlus` / `.straddlesPlus` as the only difference in where the menu sits. Content layers + sit at their resting frames inside the moving clip and cross-fade: the leaving side on + `attachContentOut` (fast, early), the arriving side on `attachContentIn` (late). +- **Menu stands on the composer's bottom edge, keyboard down.** It is 240pt wide from `+`'s left + edge, so it grows up and right over the field and leaves the send button clear. The card stands on + the same edge. +- **Back is the forward morph reversed.** `returnToMenu()` closes the card and opens the panel in + one `withAnimation`; the phase goes card → menu, the shape springs back, and the photo selection + resets. Only Add and the shutter call `focusComposer()`. +- **Add and the shutter land the surface on the chip.** `closeCard` stages the chip and calls + `AttachCard.beginLanding(chipID:)`; the strip reports the chip's window frame through + `landingChipDidLayout`, and the surface springs onto it in the `.landing` phase, staying drawn the + whole way. `endLanding()` runs without animation, so the chip replaces the surface in one frame + (with a fade, the chip showed ~350ms after the card went). `matchedGeometryEffect` is gone from + the strip; the chip only carries `landingChipID`. +- **Camera row: back, shutter, "…".** "…" (with a blue dot) unfolds flash and flip above itself and + turns into ×. The flash is off/on, resolved per shot against `output.supportedFlashModes`. + Setting a mode the output lacks raises an exception, and a flip changes what it supports. + ChatGPT's "Scan" is out of scope: there is no document-scan feature. +- **All Photos opens the system sheet.** With `.photosPickerAccessoryVisibility(.visible, edges: + .top)` the inline picker shows a prompt, a Photos/Collections switch, and the privacy banner, about + 40% of the card. Nothing outside the picker can switch it to Collections. So the accessory stays + hidden, and All Photos (shown while nothing is selected, cross-fading with Add) presents + `.photosPicker(isPresented:)` bound to the same selection. +- **Photos opens an inline card, not the system sheet.** `PhotosPicker` with + `.photosPickerStyle(.inline)` (iOS 17+, target is 18.0) is out of process and needs no photo + library permission, so the spec's reason for rejecting an inline grid (full library access) + doesn't apply on iOS. The "Private Access to Photos" notice in the recording is the picker's own. + This is an iOS-only departure from the cross-platform spec; Android keeps the system Photo Picker. + +## What the recording shows + +1. Tapping Photos: the panel grows into a rounded card while its rows blur out and the picker fades + in. The card spans from just under the transcript to the bottom of the screen, covering both the + composer row and the keyboard. The transcript stays visible above it. +2. In the card: the picker grid, a back chevron bottom-leading, and — once something is selected — + a blue "Add N photo" pill bottom-trailing. System chrome is hidden. +3. Tapping Add: the card collapses, the keyboard rises, and the photo shrinks into a chip at the top + of the composer, which grows to hold it. + +## Constraints + +- The picker is a remote view: no `matchedGeometryEffect` on it, and no way to find the selected + cell's frame. Morph a card container; the shrink starts from the card's bounds. +- Inline selection updates live, so staging moves from "selection changed" to the Add tap. +- The shrink draws the real image, so load thumbnails as the selection changes, not at Add. +- One card mechanism for camera and photos: `AttachCard`. +- With N > 1 photos the recording shows nothing; the first chip takes the shrink and the rest use the + existing chip insert transition. +- Reduce Motion: with one surface there are not two views to cross-fade, so the shape's change + runs without animation (`AttachMotion.animatesGeometry` is false) and only the content and the + surface's opacity fade. + +## Content mounted with the surface + +- **Photos:** the surface's one inline `PhotosPicker` mounts, hidden and at the card's size, from the + surface's first frame, so the morph never relays it out and warming is the real instance. + `PhotosPickerWarmer` (a second hidden picker) and the 0.5s `Task.sleep` before mounting are gone. + Selection, preloader, and the All Photos flag live in `AttachPhotosPick` on the model, reset on + Back and when the surface unmounts without a choice. Add hands the preloader to staging and swaps + in a fresh one, so the in-flight loads aren't cancelled. +- **Camera:** the preview mounts with the surface only when access is already `.authorized`; + otherwise it mounts when its card opens, which asks. `AttachWarmUp` starts the session while the + open panel offers Camera or the camera card shows, and stops it when the panel closes. + `ChatPhotoCamera.isRunning` is observable. +- **Readiness:** `AttachPanel.select(_:warmUp:…)` opens a card through `AttachWarmUp.whenReady`, which + waits at most 150ms. The camera is ready once running. The remote picker has no ready signal, so + Photos counts as ready once the picker has been mounted for `pickerLead` (300ms). +- **Measured (iOS 27 simulator, 2026-10-03, `simctl recordVideo`, 60fps frame scan for a coloured + morph marker and the grid's saturated pixels):** the morph runs 0.317s. With the extension already + started in the process, the grid is in the card ~0.1s before the morph ends (three opens: two with + the keyboard up, one down). The first open in a fresh process shows the grid 0.63s after the + morph ends, ~2.2s after the hidden mount: the out-of-process extension's cold start, which no + readiness wait inside 150ms can cover. The simulator has no camera ("Camera unavailable" is in the + card at morph end), so camera first-frame time is unmeasured. diff --git a/Flipcash/Core/Screens/Conversation/AttachCard.swift b/Flipcash/Core/Screens/Conversation/AttachCard.swift new file mode 100644 index 000000000..3ccfec088 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachCard.swift @@ -0,0 +1,159 @@ +// +// AttachCard.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Observation +import FlipcashUI + +/// The camera or photo card the attach menu expands into: a fixed share of the screen, standing on +/// the composer's bottom edge over the composer row and the transcript. +/// +/// The card floats over the transcript, outside the bar's own frame, so the screen has to let the +/// bar draw above itself for as long as the card is on screen — which outlasts ``isOpen`` by the +/// length of the exit. ``holdsOverflow`` is that longer span. +@MainActor +@Observable +final class AttachCard { + + /// What the attach menu can expand into. + nonisolated enum Content: Equatable { + case camera + case photos + } + + /// The share of the screen's height the card takes, measured off the reference recording. + nonisolated static let screenHeightFraction: CGFloat = 0.58 + + /// The card's height before a screen has been measured. + nonisolated static let fallbackHeight: CGFloat = 480 + + /// Returns the card's height on a screen `screenHeight` points tall. + nonisolated static func height(forScreenHeight screenHeight: CGFloat) -> CGFloat { + guard screenHeight > 0 else { return fallbackHeight } + return (screenHeight * screenHeightFraction).rounded() + } + + /// What is up, if anything. + private(set) var content: Content? + + /// Whether a card is up. + var isOpen: Bool { content != nil } + + /// The card's height, fixed when it opens. + private(set) var height: CGFloat = fallbackHeight + + /// Whether the bar needs room above itself: from opening until the exit has finished. + private(set) var holdsOverflow = false + + /// The chip the last capture or added photo was staged as, held hidden in the strip until the + /// surface has shrunk onto it. + private(set) var landingChipID: ComposerChip.ID? + + /// That chip's frame in window coordinates, once the bar has laid it out. + private(set) var landingChipFrame: CGRect? + + /// Whether the keyboard coming up takes the card down. Off while the card is drawn over the + /// keyboard, where the keyboard never left, and an input view swap announces it again. + @ObservationIgnored var closesOnKeyboardShow = true + + @ObservationIgnored private var observer: (any NSObjectProtocol)? + + init() { + observer = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: nil + ) { [weak self] notification in + let isLocal = notification.userInfo?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool ?? true + MainActor.assumeIsolated { + guard isLocal else { return } + self?.keyboardWillShow() + } + } + } + + isolated deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Sizes the card for a screen `screenHeight` points tall ahead of opening it, so its content can + /// be laid out at its final size while the panel is still up. + func measure(screenHeight: CGFloat) { + guard content == nil else { return } + height = Self.height(forScreenHeight: screenHeight) + } + + /// Puts `content` up on a screen `screenHeight` points tall, unless a card is already up. + func open(_ content: Content, screenHeight: CGFloat) { + guard self.content == nil else { return } + height = Self.height(forScreenHeight: screenHeight) + self.content = content + holdsOverflow = true + } + + /// Takes the card down, leaving the room above the bar until ``exitDidFinish()``. + func close() { + guard content != nil else { return } + content = nil + } + + /// Marks `chipID` as the chip the card will shrink onto, keeping the card up until the bar has + /// laid that chip out. + func beginLanding(on chipID: ComposerChip.ID) { + landingChipID = chipID + landingChipFrame = nil + } + + /// Records where the landing chip was laid out. Returns whether the card is still up and can now + /// shrink onto it. + @discardableResult + func landingChipDidLayout(_ frame: CGRect) -> Bool { + guard landingChipID != nil, frame.width > 0 else { return false } + landingChipFrame = frame + return content != nil + } + + /// Ends the landing once the surface has shrunk onto the chip: without animation, so the chip + /// takes the surface's place in one frame. + func endLanding() { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + landingChipID = nil + landingChipFrame = nil + } + } + + /// Releases the room above the bar once the exit's animation has run, unless a card came back up + /// in the meantime. + func exitDidFinish() { + guard content == nil else { return } + holdsOverflow = false + } + + /// Runs `change` on the card's curve, and calls ``exitDidFinish()`` once the motion it started has + /// logically finished. `then` runs after that, for whatever else the motion moved. + func animate(_ change: () -> Void, then: @escaping () -> Void = {}) { + withAnimation(ChatMotion.attachCard.animation, completionCriteria: .logicallyComplete) { + change() + } completion: { [weak self] in + self?.exitDidFinish() + then() + } + } + + /// The keyboard is taking the screen back from a card still up: it goes at once, with nothing to + /// animate out. A card landing on its chip — Add raises the keyboard as it starts — or already + /// leaving finishes on its own. + func keyboardWillShow() { + guard closesOnKeyboardShow, landingChipID == nil, content != nil else { return } + close() + exitDidFinish() + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift new file mode 100644 index 000000000..f36afa06a --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift @@ -0,0 +1,364 @@ +// +// AttachKeyboardOverlay.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit +import PhotosUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.attach-overlay") + +/// What the panel and cards drawn over the keyboard hand back to the screen. +struct AttachOverlayActions { + var onCash: () -> Void + var onCamera: () -> Void + var onPhotos: () -> Void + var onCameraCapture: (ChatCameraCapture) -> Void + var onCameraCancel: () -> Void + var onPhotosAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void + var onPhotosBack: () -> Void + var onAllPhotos: (() -> Void)? = nil +} + +/// Draws the attach panel and its cards over the keyboard while the composer keeps focus, as +/// ChatGPT does: in the keyboard's own window when ``KeyboardOverlayHost`` finds it, else over a +/// keyboard-coloured input view swapped in under the field. +/// +/// The SwiftUI is hosted in a full-screen container laid over the app's window, so it shares the +/// bar's window coordinates. The container takes touches only on the panel and the card; the keys +/// they leave uncovered and the app beside them keep theirs. +@MainActor +final class AttachKeyboardOverlay { + + private let model: ConversationBarModel + private let locator: any KeyboardWindowLocating + + /// How the overlay is drawn, while it is. + private(set) var mode: AttachOverlayMode? + + private weak var screen: ChatScreenViewController? + private var container: AttachOverlayContainer? + private var host: UIHostingController? + private var backdrop: AttachInputBackdrop? + private var observers: [any NSObjectProtocol] = [] + + init(model: ConversationBarModel, locator: any KeyboardWindowLocating = KeyboardOverlayHost()) { + self.model = model + self.locator = locator + } + + /// Plans how `+` opens the panel and, when it goes over the keyboard, puts the overlay up with + /// `items` as its rows. Returns the mode chosen; ``AttachOverlayMode/dismissKeyboard`` leaves the + /// caller to take the keyboard down. + func begin( + screen: ChatScreenViewController, + items: [AttachMenuItem], + composer: ComposerModel, + actions: AttachOverlayActions + ) -> AttachOverlayMode { + if let mode { return mode } + let plan = AttachOverlayPlan( + locator: locator, + keyboardHeight: screen.keyboardOverlap, + canReplaceInputView: screen.canReplaceComposerInputView + ) + logger.info("Attach panel opening", metadata: [ + "mode": "\(plan.mode.rawValue)", + "keyboardHeight": "\(screen.keyboardOverlap)", + "keyboardWindowFound": "\(plan.keyboardWindow != nil)", + "canReplaceInputView": "\(screen.canReplaceComposerInputView)", + ]) + guard plan.mode.drawsOverKeyboard, let appWindow = screen.view.window else { return .dismissKeyboard } + + self.screen = screen + // Before the switch: swapping the input view in announces the keyboard showing again, which + // must not read as the keyboard taking a card's place. + model.attachCard.closesOnKeyboardShow = false + let container = makeContainer(appWindow: appWindow, composer: composer, actions: actions) + switch plan.mode { + case .keyboardWindow: + guard let window = plan.keyboardWindow else { return refuse() } + install(container, in: window) + case .inputView: + let backdrop = AttachInputBackdrop(height: screen.keyboardOverlap) + backdrop.onWindow = { [weak self] window in + guard let self, let container = self.container else { return } + self.install(container, in: window) + } + guard screen.setComposerInputView(backdrop) else { + logger.info("Attach panel input view refused", metadata: [:]) + return refuse() + } + self.backdrop = backdrop + case .dismissKeyboard: + return .dismissKeyboard + } + + mode = plan.mode + model.overKeyboard.activate(items: items) + // A tap on the transcript reaches the app's window, not the overlay, and would lower the + // keyboard; while the menu is up it only closes the menu. + screen.setTranscriptShield( + isActive: { [weak self] in self?.container?.dismissesOnOutsideTouch() ?? false }, + onTap: { [weak self] in self?.container?.onOutsideTouch() } + ) + observe() + return plan.mode + } + + /// Undoes a begin that can't draw over the keyboard after all, leaving the bar to draw the panel. + private func refuse() -> AttachOverlayMode { + model.attachCard.closesOnKeyboardShow = true + container = nil + host = nil + return .dismissKeyboard + } + + /// Takes the overlay down once nothing is left in it, putting the system keyboard back under the + /// field if an input view stood in for it. + func end() { + guard mode != nil else { return } + mode = nil + observers.forEach(NotificationCenter.default.removeObserver) + observers = [] + screen?.setTranscriptShield(isActive: { false }, onTap: nil) + container?.isEnding = true + container?.removeFromSuperview() + container = nil + host = nil + if backdrop != nil { + screen?.setComposerInputView(nil) + backdrop = nil + } + model.attachCard.closesOnKeyboardShow = true + model.overKeyboard.deactivate() + } + + /// Takes the panel and card down at once: the keyboard went, or the app left the foreground. + func abort() { + guard mode != nil else { return } + logger.info("Attach overlay aborted", metadata: [:]) + model.resetAttach() + end() + } + + /// Hands the photo card to the bar, with the keyboard down and the full picker opening: a sheet + /// can't be presented from the keyboard's window. + func handOffToBar() { + guard mode != nil else { return } + model.overKeyboard.opensLibraryInBar = true + end() + screen?.dismissKeyboard() + } + + private func makeContainer( + appWindow: UIWindow, + composer: ComposerModel, + actions: AttachOverlayActions + ) -> AttachOverlayContainer { + let container = AttachOverlayContainer() + container.appWindow = appWindow + // The keyboard's window does not carry the app's appearance. + container.overrideUserInterfaceStyle = appWindow.traitCollection.userInterfaceStyle + container.onWindowLost = { [weak self] in self?.reattach() } + container.dismissesOnOutsideTouch = { [weak self] in + guard let model = self?.model else { return false } + return model.attachPanel.isOpen && !model.attachCard.isOpen + } + container.onOutsideTouch = { [weak self] in + self?.model.attachPanel.animate { $0.dismiss() } + } + + let root = AttachOverlayRoot( + model: model, + composer: composer, + actions: actions, + report: { [weak container] region, rect in container?.hitRects[region] = rect }, + onFinished: { [weak self] in self?.end() }, + onAbort: { [weak self] in self?.abort() } + ) + let host = UIHostingController(rootView: AnyView(root)) + host.view.backgroundColor = .clear + host.safeAreaRegions = [] + host.view.frame = container.bounds + host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(host.view) + self.host = host + self.container = container + return container + } + + private func install(_ container: AttachOverlayContainer, in window: UIWindow) { + guard let appWindow = container.appWindow else { return } + // Through the screen: the keyboard's window is in another scene, and converting straight + // from the app's window yields an infinite rect. + let onScreen = appWindow.convert(appWindow.bounds, to: appWindow.screen.coordinateSpace) + let frame = window.convert(onScreen, from: window.screen.coordinateSpace) + container.frame = frame.origin.x.isFinite && frame.origin.y.isFinite ? frame : window.bounds + if container.superview === window { + window.bringSubviewToFront(container) + } else { + window.addSubview(container) + } + } + + /// Puts the overlay back on top of a keyboard whose view tree was rebuilt, or takes it down when + /// the keyboard's window has gone. + private func reattach() { + // A turn later, so UIKit has finished swapping the tree out. + DispatchQueue.main.async { [weak self] in + guard let self, let mode = self.mode, let container = self.container, !container.isEnding else { return } + let window: UIWindow? = switch mode { + case .keyboardWindow: self.locator.keyboardWindow() + case .inputView: self.backdrop?.window + case .dismissKeyboard: nil + } + guard let window else { + self.abort() + return + } + self.install(container, in: window) + } + } + + private func observe() { + let center = NotificationCenter.default + let refresh: [Notification.Name] = [UIResponder.keyboardDidShowNotification, UIResponder.keyboardDidChangeFrameNotification] + for name in refresh { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.reattach() } + }) + } + observers.append(center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.abort() } + }) + // The input view swap announces the keyboard going and coming, so only the keyboard's own + // window takes a hide as the keyboard leaving. Focus loss covers the swap. + observers.append(center.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard self?.mode == .keyboardWindow else { return } + self?.abort() + } + }) + } +} + +/// The parts of the overlay that take touches. +enum AttachOverlayRegion: Hashable { + case panel + case card +} + +/// A full-screen layer over the keyboard that takes touches only on the panel and the card. +final class AttachOverlayContainer: UIView { + + /// The app window the overlay is laid over, whose coordinates the SwiftUI inside uses. + weak var appWindow: UIWindow? + /// The panel's and card's frames, in this view's coordinates. + var hitRects: [AttachOverlayRegion: CGRect] = [:] + /// Set as the overlay is taken down, so leaving the window is not taken for a rebuilt keyboard. + var isEnding = false + /// Fired when the keyboard's view tree is rebuilt out from under the overlay. + var onWindowLost: () -> Void = {} + /// Whether a touch outside the panel and card is taken to dismiss, instead of passing through. + var dismissesOnOutsideTouch: () -> Bool = { false } + /// Fired by a touch outside the panel and card while `dismissesOnOutsideTouch` holds. + var onOutsideTouch: () -> Void = {} + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard hitRects.values.contains(where: { $0.contains(point) }) else { + // The touch is swallowed, not typed: it only closes the menu, as with the keyboard down. + return dismissesOnOutsideTouch() ? self : nil + } + return super.hitTest(point, with: event) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + onOutsideTouch() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil, !isEnding { + onWindowLost() + } + } +} + +/// The input view that stands in for the keyboard when its window can't be reached: the keyboard's +/// own backdrop at the keyboard's height, so the composer stays where it was. +final class AttachInputBackdrop: UIInputView { + + /// Fired with the window the backdrop lands in, which the overlay is laid over. + var onWindow: (UIWindow) -> Void = { _ in } + + init(height: CGFloat) { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: height), inputViewStyle: .keyboard) + allowsSelfSizing = false + autoresizingMask = [.flexibleWidth] + } + + required init?(coder: NSCoder) { + nil + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if let window { + onWindow(window) + } + } +} + +/// The overlay's content: the attach surface in window coordinates, the menu straddling the +/// composer's bottom edge and the cards standing on the screen's bottom over the keys. +private struct AttachOverlayRoot: View { + + let model: ConversationBarModel + let composer: ComposerModel + let actions: AttachOverlayActions + let report: (AttachOverlayRegion, CGRect?) -> Void + let onFinished: () -> Void + let onAbort: () -> Void + + @State private var bounds: CGSize = .zero + + var body: some View { + ZStack(alignment: .topLeading) { + Color.clear + if model.attachSurfaceIsMounted { + AttachSurface( + model: model, + items: model.overKeyboard.items, + plus: model.overKeyboard.plusFrame, + card: AttachOverlayLayout.cardFrame(in: CGRect(origin: .zero, size: bounds), height: model.attachCard.height), + landing: model.attachCard.landingChipFrame, + menuPlacement: .straddlesPlus, + selectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), + actions: actions, + report: report + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .ignoresSafeArea() + .onGeometryChange(for: CGSize.self, of: { $0.size }) { bounds = $0 } + // Everything has gone: the overlay comes down and the bar takes over again. + .onChange(of: model.attachSurfaceIsMounted) { _, isMounted in + if !isMounted { + onFinished() + } + } + // The field losing focus takes the keyboard, and with it what was drawn over it. + .onChange(of: model.isComposing) { _, isComposing in + if !isComposing { + onAbort() + } + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index cdc7c0080..1c676bf0c 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -5,7 +5,6 @@ // Copyright © 2026 Code Inc. All rights reserved. // -import PhotosUI import SwiftUI import FlipcashUI @@ -35,38 +34,30 @@ enum AttachMenuItem: Equatable { } } -/// The composer's leading `+` control: a menu of Cash, Camera, and Photos, in the slot Send Cash -/// takes before the chat exists. -/// -/// Photos opens the system picker, which runs out of process and needs no library permission — -/// the same reason ``GalleryScanButton`` uses it. +/// The composer's leading `+` control, in the slot Send Cash takes before the chat exists. Opens the +/// attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over +/// the field and collapses back into it — and reports where it is laid out, for the surface to grow +/// from. The surface itself is ``AttachSurface``, drawn by the bar or over the keyboard. struct AttachMenu: View { let items: [AttachMenuItem] - /// The most photos one pick may return, so the strip never passes `ComposerModel.maxAttachments`. - let photosSelectionLimit: Int - let onCash: () -> Void - let onCamera: () -> Void - /// Receives the picked photos in the order they were selected. - let onPhotos: ([PhotosPickerItem]) -> Void - - @State private var isPickingPhotos = false - @State private var pickedPhotos: [PhotosPickerItem] = [] + let panel: AttachPanel + /// Whether `+` is hidden under the camera or photo card. + let hidesButton: Bool + /// Whether the attach surface is drawn in `+`'s place, which hides `+`. + var isStoodInFor = false + /// Fired as the panel opens, before it is drawn: the keyboard goes down and the panel rides the bar + /// down with it, or the panel goes up over the keyboard. + let onOpen: ([AttachMenuItem]) -> Void + /// Receives `+`'s frame in window coordinates as it is laid out. + var onPlusFrame: (CGRect) -> Void = { _ in } var body: some View { - Menu { - ForEach(items, id: \.self) { item in - switch item { - case .cash: - Button("Cash", systemImage: "banknote", action: onCash) - case .camera: - Button("Camera", systemImage: "camera", action: onCamera) - case .photos: - // Presented from the modifier below rather than as a `PhotosPicker` row: a - // picker nested in a `Menu` is torn down with the menu before it can present. - Button("Photos", systemImage: "photo.on.rectangle") { isPickingPhotos = true } - } + Button { + if !panel.isOpen { + onOpen(items) } + panel.animate { $0.toggle() } } label: { Image(systemName: SystemSymbol.plus.rawValue) .font(.default(size: 20, weight: .semibold)) @@ -74,27 +65,146 @@ struct AttachMenu: View { .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) } - // Declared order, top to bottom. The system otherwise reverses a menu that opens upward, - // which is every menu raised from the bottom bar. - .menuOrder(.fixed) .buttonStyle(.plain) .glassBackground(cornerRadius: BarMetrics.cornerRadius) .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) .accessibilityLabel("Attach") + .accessibilityValue(panel.isOpen ? "Expanded" : "Collapsed") .accessibilityIdentifier("attach-menu-button") - .photosPicker( - isPresented: $isPickingPhotos, - selection: $pickedPhotos, - maxSelectionCount: photosSelectionLimit, - selectionBehavior: .ordered, - matching: .images, - photoLibrary: .shared() - ) - .onChange(of: pickedPhotos) { _, picked in - guard !picked.isEmpty else { return } - // Cleared so the next pick starts empty instead of preselecting what was just staged. - pickedPhotos = [] - onPhotos(picked) + .hiddenUnderAttachCard(hidesButton) + // Fades under the surface growing over it, whose glass can land a frame after `+` would + // vanish; comes back at once under the surface that has shrunk into it. + .opacity(isStoodInFor ? 0 : 1) + .animation(isStoodInFor ? ChatMotion.attachContentOut : nil, value: isStoodInFor) + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { onPlusFrame($0) } + // An edit or a gate taking the slot takes the panel with it. + .onDisappear { panel.dismiss() } + } +} + +extension AttachPanel { + + /// Acts on the row `item`: Cash collapses the panel, and Camera and Photos expand it into their + /// card once `warmUp` has the card's content ready, or has waited as long as it will. + func select( + _ item: AttachMenuItem, + warmUp: AttachWarmUp, + onCash: () -> Void, + onCamera: @escaping () -> Void, + onPhotos: @escaping () -> Void + ) { + switch item { + case .cash: + animate { $0.dismiss() } + onCash() + case .camera: + warmUp.whenReady(for: .camera) { [weak self] in self?.expand(into: onCamera) } + case .photos: + warmUp.whenReady(for: .photos) { [weak self] in self?.expand(into: onPhotos) } + } + } + + /// Expands the panel into the card `open` puts up: one transaction, so the surface springs from + /// the menu's frame to the card's while the rows fade out and the card's content in. + private func expand(into open: () -> Void) { + guard isOpen else { return } + withAnimation(ChatMotion.attachCard.animation, completionCriteria: .logicallyComplete) { + dismiss() + open() + } completion: { [weak self] in + self?.exitDidFinish() + } + } +} + +/// Takes the attach panel down at the first touch outside it: over the transcript, and over the +/// field beside `+`. Hidden from VoiceOver, which dismisses with the escape gesture instead. +struct AttachPanelDismissArea: View { + + let panel: AttachPanel + + var body: some View { + Color.clear + .contentShape(Rectangle()) + // A drag from zero rather than a tap, so the touch that starts a scroll dismisses too + // instead of being swallowed. + .gesture(DragGesture(minimumDistance: 0).onChanged { _ in + panel.animate { $0.dismiss() } + }) + .accessibilityHidden(true) + } +} + +/// The attach menu's rows, top to bottom, laid out at the menu's width. The surface they sit on is +/// ``AttachSurface``'s. +struct AttachMenuRows: View { + + let items: [AttachMenuItem] + let onSelect: (AttachMenuItem) -> Void + /// Fired by the escape gesture. + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(items, id: \.self) { item in + Button { + onSelect(item) + } label: { + Label(item.title, systemImage: item.systemImage) + .labelStyle(AttachMenuRowLabelStyle()) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, AttachSurfaceLayout.menuVerticalPadding) + .frame(width: AttachSurfaceLayout.menuWidth, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + .accessibilityAction(.escape, onDismiss) + .accessibilityIdentifier("attach-menu-panel") + } +} + +/// A panel row: the glyph in a tinted disc so the titles line up, and the whole row the target. +private struct AttachMenuRowLabelStyle: LabelStyle { + + private static let iconDiameter: CGFloat = 42 + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 14) { + configuration.icon + .font(.default(size: 19, weight: .medium)) + .frame(width: Self.iconDiameter, height: Self.iconDiameter) + .background(Color.white.opacity(0.08), in: Circle()) + configuration.title + .font(.default(size: 18, weight: .regular)) + } + .foregroundStyle(Color.textMain) + .padding(.horizontal, 18) + .frame(minHeight: AttachSurfaceLayout.menuRowHeight, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } +} + +extension AttachMenuItem { + + /// The row's title, which is also its VoiceOver label. + var title: String { + switch self { + case .cash: "Cash" + case .camera: "Camera" + case .photos: "Photos" + } + } + + /// The row's SF Symbol. + var systemImage: String { + switch self { + case .cash: "banknote" + case .camera: "camera" + case .photos: "photo.on.rectangle" } } } diff --git a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift new file mode 100644 index 000000000..3598fa809 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift @@ -0,0 +1,129 @@ +// +// AttachOverKeyboard.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Observation +import FlipcashCore + +/// How `+` puts the attach panel up while the keyboard is up. +nonisolated enum AttachOverlayMode: String, Equatable { + /// Over the live keys, in the keyboard's own window, which ``KeyboardOverlayHost`` finds. + case keyboardWindow + /// Over a plain keyboard-coloured backdrop swapped in as the field's input view, in the window + /// that input view lands in. + case inputView + /// The keyboard goes down first and the panel rides the bar down with it. + case dismissKeyboard + + /// The least keyboard height that counts as a software keyboard. A hardware keyboard leaves only + /// the shortcut bar, which is no keyboard to draw over. + static let minimumKeyboardHeight: CGFloat = 150 + + /// Picks the mode for a keyboard `keyboardHeight` points tall, given whether its window was found + /// and whether the field can take a replacement input view. + static func select(keyboardHeight: CGFloat, hasKeyboardWindow: Bool, canReplaceInputView: Bool) -> Self { + guard keyboardHeight >= minimumKeyboardHeight else { return .dismissKeyboard } + if hasKeyboardWindow { return .keyboardWindow } + if canReplaceInputView { return .inputView } + return .dismissKeyboard + } + + /// Whether the panel and its cards are drawn over the keyboard rather than in the bar. + var drawsOverKeyboard: Bool { + switch self { + case .keyboardWindow, .inputView: true + case .dismissKeyboard: false + } + } +} + +/// The attach mode chosen for one tap of `+`, with the keyboard window when that is where it goes. +@MainActor +struct AttachOverlayPlan { + let mode: AttachOverlayMode + let keyboardWindow: UIWindow? + + /// Plans the mode with `locator` asked for the keyboard's window, which it may not find. + init(locator: any KeyboardWindowLocating, keyboardHeight: CGFloat, canReplaceInputView: Bool) { + // Asked only for a keyboard tall enough to draw over, so the private lookup runs no more + // than it has to. + let window = keyboardHeight >= AttachOverlayMode.minimumKeyboardHeight ? locator.keyboardWindow() : nil + mode = AttachOverlayMode.select( + keyboardHeight: keyboardHeight, + hasKeyboardWindow: window != nil, + canReplaceInputView: canReplaceInputView + ) + keyboardWindow = mode == .keyboardWindow ? window : nil + } +} + +/// Whether the attach panel and its cards are drawn over the keyboard, and what that drawing needs +/// from the bar underneath it: where `+` and the field are, and the chip a photo lands as. +/// +/// The bar lives in the app's window and the overlay in the keyboard's, so they can't share a +/// `matchedGeometryEffect` namespace. The card shrinks onto a stand-in at the landing chip's frame +/// instead, which the bar measures and hands over here. +@MainActor +@Observable +final class AttachOverKeyboard { + + /// Whether the panel and cards are drawn over the keyboard rather than in the bar. + private(set) var isActive = false + + /// The menu rows the panel showed as it opened. + private(set) var items: [AttachMenuItem] = [] + + /// `+`'s frame in window coordinates, as last laid out. + var plusFrame: CGRect = .zero + + /// Whether the photo card opens the full library as it next mounts in the bar. Set when All + /// Photos hands the card over to the keyboard-down flow, whose sheet the keyboard would cover. + var opensLibraryInBar = false + + /// Starts drawing over the keyboard, with `items` as the panel's rows. + func activate(items: [AttachMenuItem]) { + self.items = items + isActive = true + } + + /// Hands drawing back to the bar. + func deactivate() { + isActive = false + } +} + +nonisolated enum AttachOverlayLayout { + + /// The margin between a card and the screen's sides and bottom: the bar's keyboard-up edge inset. + static let cardInset: CGFloat = 12 + + /// The panel's frame for a panel `size` big: its leading edge on `+`'s, and centred on `+`'s + /// bottom, the composer's bottom edge. It straddles that edge, as ChatGPT's does, so its lower + /// half lies over the keys that blur through it and its upper half over the field. + static func panelFrame(plusFrame: CGRect, size: CGSize) -> CGRect { + CGRect(x: plusFrame.minX, y: plusFrame.maxY - size.height / 2, width: size.width, height: size.height) + } + + /// The point in a panel `size` big that `+`'s centre sits on, for the panel to grow out of and + /// collapse back into. + static func panelAnchor(plusFrame: CGRect, size: CGSize) -> UnitPoint { + guard size.width > 0, size.height > 0 else { return .leading } + let frame = panelFrame(plusFrame: plusFrame, size: size) + return UnitPoint( + x: min(1, max(0, (plusFrame.midX - frame.minX) / size.width)), + y: min(1, max(0, (plusFrame.midY - frame.minY) / size.height)) + ) + } + + /// The card's frame in a window `bounds` big: `height` tall, standing on the screen's bottom + /// over the keyboard. + static func cardFrame(in bounds: CGRect, height: CGFloat) -> CGRect { + let width = max(0, bounds.width - cardInset * 2) + let height = min(height, max(0, bounds.height - cardInset)) + return CGRect(x: bounds.minX + cardInset, y: bounds.maxY - cardInset - height, width: width, height: height) + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachPanel.swift b/Flipcash/Core/Screens/Conversation/AttachPanel.swift new file mode 100644 index 000000000..369aca1bc --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachPanel.swift @@ -0,0 +1,117 @@ +// +// AttachPanel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Observation +import FlipcashUI + +/// Whether the attach menu's floating panel is up over the composer row, and how long it holds room +/// above the bar. +/// +/// The panel floats over the transcript, outside the bar's own frame, so the screen has to let the +/// bar draw and take touches above itself for as long as the panel is on screen — which outlasts +/// ``isOpen`` by the length of the exit. ``holdsOverflow`` is that longer span. +@MainActor +@Observable +final class AttachPanel { + + /// Whether the panel is up. + private(set) var isOpen = false + + /// Whether the bar needs room above itself: from opening until the exit has finished. + private(set) var holdsOverflow = false + + /// Opens the panel when closed and collapses it when open, as `+` does. + func toggle() { + if isOpen { + dismiss() + } else { + open() + } + } + + /// Puts the panel up: out of `+`, or back out of a card. + func open() { + guard !isOpen else { return } + isOpen = true + holdsOverflow = true + } + + /// Takes the panel down: into `+`, or into the card it expands into. + func dismiss() { + guard isOpen else { return } + isOpen = false + } + + /// Runs `change` on the panel's spring, and releases the room above the bar once the motion it + /// started has logically finished. + func animate(_ change: (AttachPanel) -> Void) { + withAnimation(ChatMotion.attachPanel.animation, completionCriteria: .logicallyComplete) { + change(self) + } completion: { [weak self] in + self?.exitDidFinish() + } + } + + /// Releases the room above the bar once the exit's animation has run, unless the panel came back + /// in the meantime. + func exitDidFinish() { + guard !isOpen else { return } + holdsOverflow = false + } +} + +/// How the attach flow moves: the one surface springing between `+`, the panel, a card, and a chip, +/// or — under Reduce Motion — standing still at each while its content cross-fades. +nonisolated enum AttachMotion: Equatable { + case morph + case crossFade + + init(reduceMotion: Bool) { + self = reduceMotion ? .crossFade : .morph + } + + /// Whether the surface's frame and corner radius animate, rather than jump. + var animatesGeometry: Bool { + switch self { + case .morph: true + case .crossFade: false + } + } + + /// A chip's scale as it enters the strip and leaves it. + var chipEnterScale: CGFloat { + switch self { + case .morph: ChatMotion.composerChipEnterScale + case .crossFade: 1 + } + } + + /// A chip's transition in and out of the strip, for a chip that is or isn't the one the surface is + /// landing on — that one arrives hidden, where it lies. + func chipTransition(isLanding: Bool) -> AnyTransition { + guard !isLanding else { return .identity } + return .scale(scale: chipEnterScale).combined(with: .opacity) + } + + /// The strip's transition as its first chip arrives and its last one leaves: grown from the + /// leading edge, where that chip sits. + func stripTransition(isLanding: Bool) -> AnyTransition { + guard !isLanding else { return .identity } + return .scale(scale: chipEnterScale, anchor: .leading).combined(with: .opacity) + } +} + +extension View { + /// Fades this view under the camera or photo card: out before the card has grown over it, and back + /// in once the card has shrunk away. Scoped, so nothing else inside rides these curves. + func hiddenUnderAttachCard(_ hidden: Bool) -> some View { + animation(hidden ? ChatMotion.attachContentOut : ChatMotion.attachContentIn) { + $0.opacity(hidden ? 0 : 1) + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift new file mode 100644 index 000000000..9862f055f --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -0,0 +1,298 @@ +// +// AttachSurface.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI + +/// What the attach surface is showing. +nonisolated enum AttachSurfacePhase: Equatable { + /// Shrunk into `+` and faded out: on its way in or out. + case collapsed + /// The menu's rows. + case menu + /// The camera or photo card. + case card(AttachCard.Content) + /// Shrinking onto the chip a capture or added photo was staged as. + case landing + + /// The phase for a panel that `isMenuOpen`, the card `card` up if any, and whether a landing is + /// under way. A card wins over the panel, which closes in the transaction the card opens in. + init(isMenuOpen: Bool, card: AttachCard.Content?, isLanding: Bool) { + if let card { + self = .card(card) + } else if isMenuOpen { + self = .menu + } else if isLanding { + self = .landing + } else { + self = .collapsed + } + } + + /// Whether the menu's rows are shown. + var showsRows: Bool { + switch self { + case .menu: true + case .collapsed, .card, .landing: false + } + } + + /// The card whose content is shown, if any. + var shownCard: AttachCard.Content? { + switch self { + case .card(let content): content + case .collapsed, .menu, .landing: nil + } + } +} + +/// Where the menu stands against `+`. +nonisolated enum AttachMenuPlacement: Equatable { + /// On the composer's bottom edge, growing up out of `+`: in the bar, with the keyboard down. + case standsOnPlus + /// Centred on the composer's bottom edge, over the keys: drawn over the keyboard. + case straddlesPlus +} + +/// The surface's frame and corner radius, which spring together. +nonisolated struct AttachSurfaceShape: Equatable { + var rect: CGRect + var cornerRadius: CGFloat +} + +/// Where the attach surface stands in each phase, in its host's coordinates. +enum AttachSurfaceLayout { + + /// `+`'s radius, which the surface grows out of and collapses back into. + static let plusCornerRadius: CGFloat = BarMetrics.cornerRadius + static let menuCornerRadius: CGFloat = 40 + static let cardCornerRadius: CGFloat = 24 + /// A staged chip's radius, which a landing ends on. + static let chipCornerRadius: CGFloat = 10 + /// The menu's width: enough to cover the field from `+`, short of the send button on a 375pt screen. + static let menuWidth: CGFloat = 280 + /// A menu row's height, sized to the system's own attachment menus. + static let menuRowHeight: CGFloat = 62 + static let menuVerticalPadding: CGFloat = 10 + + /// The menu's size before its rows are measured. + static func estimatedMenuSize(rowCount: Int) -> CGSize { + CGSize(width: menuWidth, height: CGFloat(rowCount) * menuRowHeight + menuVerticalPadding * 2) + } + + /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. + static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { + switch placement { + case .standsOnPlus: + CGRect(x: plus.minX, y: plus.maxY - size.height, width: size.width, height: size.height) + case .straddlesPlus: + AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) + } + } + + /// The card's frame in the bar, in the composer row's coordinates: standing on the row's bottom + /// edge, `height` tall, and `outset` wider than the row on each side. + static func barCardRect(row: CGSize, outset: CGFloat, height: CGFloat) -> CGRect { + CGRect(x: -outset, y: row.height - height, width: row.width + outset * 2, height: height) + } + + /// The surface's shape in `phase`. A landing whose chip hasn't been laid out yet holds the card's. + static func shape(for phase: AttachSurfacePhase, plus: CGRect, menu: CGRect, card: CGRect, landing: CGRect?) -> AttachSurfaceShape { + switch phase { + case .collapsed: + AttachSurfaceShape(rect: plus, cornerRadius: plusCornerRadius) + case .menu: + AttachSurfaceShape(rect: menu, cornerRadius: menuCornerRadius) + case .card: + AttachSurfaceShape(rect: card, cornerRadius: cardCornerRadius) + case .landing: + if let landing { + AttachSurfaceShape(rect: landing, cornerRadius: chipCornerRadius) + } else { + AttachSurfaceShape(rect: card, cornerRadius: cardCornerRadius) + } + } + } + + /// The regions that take touches in `phase`: the menu while it shows, the card while it is up. + static func regions(for phase: AttachSurfacePhase, menu: CGRect, card: CGRect) -> [AttachOverlayRegion: CGRect] { + switch phase { + case .menu: [.panel: menu] + case .card: [.card: card] + case .collapsed, .landing: [:] + } + } +} + +/// The attach flow's one surface: a single glass shape that grows out of `+` into the menu, springs +/// between the menu and the camera or photo card, and shrinks onto the chip a photo is staged as. +/// +/// Only its frame and corner radius move. Its content is laid out at its final size from the moment +/// the surface appears — the camera's viewfinder and the inline photo picker at the card's, the rows +/// at the menu's — and cross-fades inside the clip: the leaving side out early and fast, the arriving +/// side in late. Drawn in the bar with the keyboard down, and over the keyboard with it up; the host +/// passes `+`'s frame, the card's, and the landing chip's in its own coordinates. +struct AttachSurface: View { + + let model: ConversationBarModel + let items: [AttachMenuItem] + let plus: CGRect + let card: CGRect + let landing: CGRect? + let menuPlacement: AttachMenuPlacement + let selectionLimit: Int + let actions: AttachOverlayActions + /// Whether the photo card opens the full picker as it mounts, for one All Photos was handed to. + var opensLibraryOnAppear = false + /// Receives the regions that take touches as they change, and none once the surface is gone. + var report: (AttachOverlayRegion, CGRect?) -> Void = { _, _ in } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// Off for the first render, so the surface starts in `+` and grows out of it. + @State private var appeared = false + @State private var menuSize: CGSize? + + var body: some View { + let motion = AttachMotion(reduceMotion: reduceMotion) + let phase = model.attachSurfacePhase + let menu = AttachSurfaceLayout.menuRect( + plus: plus, + size: menuSize ?? AttachSurfaceLayout.estimatedMenuSize(rowCount: items.count), + placement: menuPlacement + ) + let shape = AttachSurfaceLayout.shape(for: appeared ? phase : .collapsed, plus: plus, menu: menu, card: card, landing: landing) + // Morphing, the surface is `+` at either end and stays opaque; a cross-fade fades it instead. + let isVisible = motion.animatesGeometry || (appeared && phase != .collapsed) + let showsPlusGlyph = !appeared || phase == .collapsed + AttachSurfaceFrame(shape: shape) { rect in + ZStack(alignment: .topLeading) { + plusGlyph + .offset(x: plus.minX - rect.minX, y: plus.minY - rect.minY) + .attachLayer(isShown: showsPlusGlyph) + cardLayer(phase: phase) + .frame(width: card.width, height: card.height) + .offset(x: card.minX - rect.minX, y: card.minY - rect.minY) + rows(phase: phase) + .frame(width: menu.width, height: menu.height, alignment: .topLeading) + .offset(x: menu.minX - rect.minX, y: menu.minY - rect.minY) + } + } + // Under Reduce Motion the shape jumps; only the content and the surface's own opacity fade. + .transaction(value: shape) { transaction in + if !motion.animatesGeometry { + transaction.animation = nil + } + } + .opacity(isVisible ? 1 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { + withAnimation(ChatMotion.attachPanel.animation) { appeared = true } + } + .onChange(of: AttachSurfaceLayout.regions(for: phase, menu: menu, card: card), initial: true) { _, regions in + report(.panel, regions[.panel]) + report(.card, regions[.card]) + } + .onDisappear { + report(.panel, nil) + report(.card, nil) + } + } + + // MARK: - Content - + + /// `+`'s glyph where `+` stands, so the surface reads as `+` itself as it grows and collapses. + private var plusGlyph: some View { + Image(systemName: SystemSymbol.plus.rawValue) + .font(.default(size: 20, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: plus.width, height: plus.height) + .accessibilityHidden(true) + } + + private func rows(phase: AttachSurfacePhase) -> some View { + let panel = model.attachPanel + return AttachMenuRows(items: items, onSelect: { item in + panel.select(item, warmUp: model.attachWarmUp, onCash: actions.onCash, onCamera: actions.onCamera, onPhotos: actions.onPhotos) + }) { + panel.animate { $0.dismiss() } + } + .onGeometryChange(for: CGSize.self, of: { $0.size }) { menuSize = $0 } + .attachLayer(isShown: phase.showsRows) + } + + /// The camera and the photo picker, each mounted from the surface's first frame at the card's + /// size, so neither is laid out again as the surface grows into it. The camera only once access + /// is granted: mounted earlier, it would ask while the menu is up. + private func cardLayer(phase: AttachSurfacePhase) -> some View { + let shown = phase.shownCard + let mountsCamera = items.contains(.camera) && (model.attachWarmUp.cameraIsAuthorized || shown == .camera) + return ZStack { + if mountsCamera { + ChatCameraSheet(camera: model.attachWarmUp.camera, onCapture: actions.onCameraCapture, onCancel: actions.onCameraCancel) + .attachLayer(isShown: shown == .camera) + } + if items.contains(.photos) { + ChatPhotosCard( + pick: model.photosPick, + selectionLimit: selectionLimit, + onAdd: actions.onPhotosAdd, + onBack: actions.onPhotosBack, + onAllPhotos: actions.onAllPhotos, + opensLibraryOnAppear: opensLibraryOnAppear + ) + .onAppear { model.attachWarmUp.pickerDidMount() } + .onDisappear { model.attachWarmUp.pickerDidUnmount() } + .attachLayer(isShown: shown == .photos) + } + } + } +} + +private extension View { + + /// Shows this layer of the surface's content, or keeps it mounted but hidden and out of reach: + /// fading in late as the surface arrives at it, and out early as the surface leaves. + func attachLayer(isShown: Bool) -> some View { + animation(isShown ? ChatMotion.attachContentIn : ChatMotion.attachContentOut) { + $0.opacity(isShown ? 1 : 0) + } + .allowsHitTesting(isShown) + .accessibilityHidden(!isShown) + } +} + +/// The surface's glass and clip at one frame and corner radius, which animate as one value so the +/// two never drift apart mid-spring. `content` is handed the frame of each animated step, to keep +/// its layers where they rest while the clip moves over them. +private struct AttachSurfaceFrame: View, Animatable { + + var shape: AttachSurfaceShape + let content: (CGRect) -> Content + + var animatableData: AnimatablePair { + get { AnimatablePair(shape.rect.animatableData, shape.cornerRadius) } + set { + shape.rect.animatableData = newValue.first + shape.cornerRadius = newValue.second + } + } + + var body: some View { + let rect = shape.rect + let clip = RoundedRectangle(cornerRadius: shape.cornerRadius, style: .continuous) + ZStack(alignment: .topLeading) { + Color.clear + .frame(width: rect.width, height: rect.height) + .panelGlassBackground(cornerRadius: shape.cornerRadius) + content(rect) + } + .frame(width: rect.width, height: rect.height, alignment: .topLeading) + .clipShape(clip) + .contentShape(clip) + .offset(x: rect.minX, y: rect.minY) + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachWarmUp.swift b/Flipcash/Core/Screens/Conversation/AttachWarmUp.swift new file mode 100644 index 000000000..bcd12c4d0 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachWarmUp.swift @@ -0,0 +1,121 @@ +// +// AttachWarmUp.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import AVFoundation +import Observation + +/// Gets the camera running while the attach panel is up, so the camera card opens on a live frame +/// instead of waiting for the session to configure and start. +/// +/// Warm while the panel is up or the camera card is; cold otherwise, so no capture session runs, and +/// the camera light stays off, while the menu is closed. Never asks for camera access: a camera that +/// hasn't been granted is left for the card to ask about. +@MainActor +@Observable +final class AttachWarmUp { + + /// Whether the panel or a card is up, and the camera with it if access allows. + private(set) var isWarm = false + + @ObservationIgnored private var warmCamera: ChatPhotoCamera? + @ObservationIgnored private let cameraAccess: () -> AVAuthorizationStatus + + init(cameraAccess: @escaping () -> AVAuthorizationStatus = { AVCaptureDevice.authorizationStatus(for: .video) }) { + self.cameraAccess = cameraAccess + } + + /// The camera the card shows, made on first use and kept for the screen's lifetime. + var camera: ChatPhotoCamera { + if let warmCamera { + return warmCamera + } + let camera = ChatPhotoCamera() + warmCamera = camera + return camera + } + + /// Whether the camera has been made. A chat whose attach menu is never opened never makes one. + var hasCamera: Bool { warmCamera != nil } + + /// Whether camera access is granted, so the camera can be mounted without asking for it. + var cameraIsAuthorized: Bool { cameraAccess() == .authorized } + + /// The longest a card waits for its content before opening anyway. + static let readinessBudget: Duration = .milliseconds(150) + + /// How long the inline picker has to have been mounted before its grid counts as drawn. The picker + /// is remote and reports nothing, so this stands in for a signal. + static let pickerLead: Duration = .milliseconds(300) + + /// When the surface's inline photo picker was mounted, while it is. + @ObservationIgnored private(set) var pickerMountedAt: ContinuousClock.Instant? + + /// Records that the surface mounted its inline photo picker. + func pickerDidMount() { + pickerMountedAt = .now + } + + /// Records that the surface took its inline photo picker down. + func pickerDidUnmount() { + pickerMountedAt = nil + } + + /// Whether `item`'s card content is ready to be revealed at `now`: the camera running, or the + /// picker mounted for ``pickerLead``. A camera without access has nothing to wait for, since its + /// card asks for access instead. + func isReady(for item: AttachMenuItem, at now: ContinuousClock.Instant = .now) -> Bool { + switch item { + case .cash: + return true + case .camera: + return !cameraIsAuthorized || (warmCamera?.isRunning ?? false) + case .photos: + guard let pickerMountedAt else { return false } + return now - pickerMountedAt >= Self.pickerLead + } + } + + /// Runs `body` once `item`'s card content is ready, or after ``readinessBudget`` at the most, so + /// the card never opens empty for want of a few frames and never waits long for content that is + /// slow to come. + func whenReady(for item: AttachMenuItem, _ body: @escaping @MainActor () -> Void) { + guard !isReady(for: item) else { + body() + return + } + let deadline = ContinuousClock.now + Self.readinessBudget + Task { @MainActor [weak self] in + while let self, !self.isReady(for: item), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(16)) + } + body() + } + } + + /// Starts warming when `wanted` and stops when not, as the panel and the cards come and go. + func update(wanted: Bool) { + if wanted { + warm() + } else { + cool() + } + } + + private func warm() { + guard !isWarm else { return } + isWarm = true + guard cameraAccess() == .authorized else { return } + let camera = camera + Task { try? await camera.prepare() } + } + + private func cool() { + guard isWarm else { return } + isWarm = false + warmCamera?.stop() + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift index 388167b66..95a4e1124 100644 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -35,25 +35,43 @@ nonisolated enum ChatCameraPhase: Equatable { } } -/// The attach menu's Camera: a rounded viewfinder with a shutter, a lens flip, and a back chevron, -/// sized by its host to the keyboard's frame. +/// A photo the chat camera took, with a copy decoded at the viewfinder's size for the chip it +/// shrinks into. +struct ChatCameraCapture { + let image: UIImage + let preview: UIImage? +} + +/// The attach menu's Camera: a rounded viewfinder with a back chevron, a shutter, and a "…" button +/// that unfolds the flash and lens flip above itself, sized by its host as an ``AttachCard``. /// /// Asks for camera access on first use, since opening the camera is the request. A capture hands /// the photo to `onCapture`; the host stages it and collapses the camera. struct ChatCameraSheet: View { - let onCapture: (UIImage) -> Void + /// The camera, owned by ``AttachWarmUp`` so it can already be running when this appears. + let camera: ChatPhotoCamera + let onCapture: (ChatCameraCapture) -> Void let onCancel: () -> Void + /// The viewfinder's size in points, for decoding the capture's preview at a size that stays + /// sharp for the whole shrink into its chip. + @State private var viewfinderSize: CGSize = .zero + @State private var authorizer = CameraAuthorizer() - @State private var camera = ChatPhotoCamera() @State private var isUnavailable = false + /// Whether the flash and flip buttons are unfolded above "…". + @State private var showsMoreControls = false - private static let cornerRadius: CGFloat = 24 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private static let controlSize: CGFloat = 44 var body: some View { + let phase = ChatCameraPhase(status: authorizer.status) + let isLive = phase == .live && !isUnavailable ZStack { - switch ChatCameraPhase(status: authorizer.status) { + switch phase { case .requestingAccess: ProgressView() .task { _ = try? await authorizer.authorize() } @@ -75,91 +93,169 @@ struct ChatCameraSheet: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .onGeometryChange(for: CGSize.self, of: { $0.size }) { viewfinderSize = $0 } .background(Color.backgroundMain) - .overlay(alignment: .topLeading) { - backButton + // On every phase, so the way back is there while access is asked for or denied too. + .overlay(alignment: .bottom) { + controls(isLive: isLive) } - .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) } private var viewfinder: some View { ChatCameraPreview(session: camera.session) - .overlay(alignment: .bottom) { - controls - } + // Already running when the panel warmed it; this only catches a camera granted just now. + // The warm-up stops it once the card is gone. .task { do { - try camera.configure() - camera.start() + try await camera.prepare() } catch { isUnavailable = true } } - .onDisappear { - camera.stop() - } } - private var controls: some View { + /// Back on the left, the shutter on the centre line, and "…" on the right; the shutter and "…" + /// only while the viewfinder runs. + private func controls(isLive: Bool) -> some View { HStack { - // Balances the flip button so the shutter sits on the centre line. - Color.clear - .frame(width: 44, height: 44) + backButton Spacer() - Button { - Task { - if let image = await camera.capture() { - onCapture(image) - } - } - } label: { - Circle() - .strokeBorder(Color.white, lineWidth: 4) - .padding(-2) - .overlay { - Circle() - .fill(Color.white) - .padding(6) - } - .frame(width: 68, height: 68) + if isLive { + shutter } - .buttonStyle(.plain) - .disabled(camera.isCapturing) - .accessibilityLabel("Take photo") - .accessibilityIdentifier("chat-camera-shutter") Spacer() - Button { - camera.flip() - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.default(size: 17, weight: .semibold)) - .foregroundStyle(Color.white) - .frame(width: 44, height: 44) - .background(Circle().fill(Color.black.opacity(0.35))) + if isLive { + moreButton + } else { + // Balances the back button, so nothing shifts as the viewfinder comes up. + Color.clear + .frame(width: Self.controlSize, height: Self.controlSize) } - .buttonStyle(.plain) - .accessibilityLabel(camera.position == .front ? "Use back camera" : "Use front camera") - .accessibilityIdentifier("chat-camera-flip") } - .padding(.horizontal, 24) + .padding(.horizontal, 20) .padding(.bottom, 16) } + private var shutter: some View { + Button { + Task { + guard let image = await camera.capture() else { return } + // Decoded off the main thread before the hand-off, so the chip the capture + // shrinks into has pixels on its first frame. + let scale = UITraitCollection.current.displayScale + // Square on the longer side: the thumbnail keeps the photo's aspect, and a + // portrait capture fitted into the wide viewfinder would come out small. + let side = max(viewfinderSize.width, viewfinderSize.height) * scale + let preview = side > 0 ? await image.byPreparingThumbnail(ofSize: CGSize(width: side, height: side)) : nil + onCapture(ChatCameraCapture(image: image, preview: preview)) + } + } label: { + Circle() + .strokeBorder(Color.white, lineWidth: 4) + .padding(-2) + .overlay { + Circle() + .fill(Color.white) + .padding(6) + } + .frame(width: 68, height: 68) + } + .buttonStyle(.plain) + .disabled(camera.isCapturing) + .accessibilityLabel("Take photo") + .accessibilityIdentifier("chat-camera-shutter") + } + + /// "…", turning into × while the flash and flip stand unfolded in a column above it. + private var moreButton: some View { + Button { + withAnimation(ChatMotion.attachPanel.animation) { + showsMoreControls.toggle() + } + } label: { + ChatCameraControlLabel(systemImage: showsMoreControls ? "xmark" : "ellipsis") + .overlay(alignment: .topTrailing) { + if !showsMoreControls { + Circle() + .fill(Color.blue) + .frame(width: 8, height: 8) + .offset(x: -2, y: 2) + .transition(.opacity) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(showsMoreControls ? "Hide camera controls" : "More camera controls") + .accessibilityIdentifier("chat-camera-more") + // An overlay, so unfolding the column leaves the row where it is. + .overlay(alignment: .bottom) { + if showsMoreControls { + VStack(spacing: 12) { + if camera.isFlashAvailable { + flashButton + } + flipButton + } + .fixedSize() + .padding(.bottom, Self.controlSize + 12) + .transition( + reduceMotion + ? .opacity + : .scale(scale: ChatMotion.attachPanelEnterScale, anchor: .bottom).combined(with: .opacity) + ) + } + } + } + + private var flashButton: some View { + let isOn = camera.flashMode == .on + return Button { + camera.toggleFlash() + } label: { + ChatCameraControlLabel(systemImage: isOn ? "bolt.fill" : "bolt.slash.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Flash") + .accessibilityValue(isOn ? "On" : "Off") + .accessibilityIdentifier("chat-camera-flash") + } + + private var flipButton: some View { + Button { + camera.flip() + } label: { + ChatCameraControlLabel(systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.plain) + .accessibilityLabel(camera.position == .front ? "Use back camera" : "Use front camera") + .accessibilityIdentifier("chat-camera-flip") + } + private var backButton: some View { Button(action: onCancel) { - Image(systemName: "chevron.left") - .font(.default(size: 17, weight: .semibold)) - .foregroundStyle(Color.white) - .frame(width: 44, height: 44) - .background(Circle().fill(Color.black.opacity(0.35))) + ChatCameraControlLabel(systemImage: "chevron.left") } .buttonStyle(.plain) - .padding(12) - .accessibilityLabel("Back to keyboard") + .accessibilityLabel("Back to attach menu") .accessibilityIdentifier("chat-camera-back") } } +/// A round control over the viewfinder: a white glyph on a dimmed disc. +private struct ChatCameraControlLabel: View { + + let systemImage: String + + var body: some View { + Image(systemName: systemImage) + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 44, height: 44) + .background(Circle().fill(Color.black.opacity(0.35))) + .contentShape(Circle()) + } +} + // MARK: - ChatCameraPreview - /// The live feed of a capture session, filling its frame. diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift deleted file mode 100644 index c99740585..000000000 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// ChatCameraSlot.swift -// Flipcash -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import UIKit -import Observation - -/// Whether the inline camera holds the keyboard's place under the composer, and how tall that -/// place is. -@MainActor -@Observable -final class ChatCameraSlot { - - /// The height the camera takes before a keyboard has risen on this screen. - static let fallbackHeight: CGFloat = 300 - - /// Whether the camera is up in the keyboard's place. - private(set) var isOpen = false - - /// The keyboard's height above the bottom safe area, the last time it rose. - private(set) var height: CGFloat = fallbackHeight - - @ObservationIgnored private var observer: (any NSObjectProtocol)? - - init() { - observer = NotificationCenter.default.addObserver( - forName: UIResponder.keyboardWillShowNotification, - object: nil, - queue: nil - ) { [weak self] notification in - let info = notification.userInfo - let endFrame = info?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - let isLocal = info?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool ?? true - MainActor.assumeIsolated { - guard isLocal, let endFrame, !endFrame.isEmpty else { return } - let safeAreaBottom = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 - self?.keyboardWillShow(height: endFrame.height, safeAreaBottom: safeAreaBottom) - } - } - } - - isolated deinit { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - } - - /// Puts the camera up in the keyboard's place. - func open() { - guard !isOpen else { return } - isOpen = true - } - - /// Takes the camera down. - func close() { - guard isOpen else { return } - isOpen = false - } - - /// Records a keyboard `height` tall rising over a window whose bottom safe area is - /// `safeAreaBottom`, and closes the camera, since the keyboard is taking its place back. - func keyboardWillShow(height keyboardHeight: CGFloat, safeAreaBottom: CGFloat) { - let slotHeight = keyboardHeight - safeAreaBottom - if slotHeight > 0, slotHeight != height { - height = slotHeight - } - close() - } -} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift index e3c273f78..2b48a3ce6 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift @@ -14,7 +14,7 @@ import Observation @preconcurrency import AVFoundation /// A still-photo camera for the chat composer: one capture session with a photo output, flippable -/// between the back and front lenses. +/// between the back and front lenses, with a flash the shutter fires when the lens supports it. /// /// Separate from FlipcashUI's `CameraSession`, which wires only video-data and metadata outputs for /// scanning and has no still-photo output. @@ -22,7 +22,7 @@ import Observation final class ChatPhotoCamera { /// Why the camera could not be set up. - enum SetupError: Error { + nonisolated enum SetupError: Error { /// The device has no camera at the requested position, as on the simulator. case deviceUnavailable /// The session refused the camera's input or the photo output. @@ -35,6 +35,15 @@ final class ChatPhotoCamera { /// Whether a shutter press is still being processed. private(set) var isCapturing = false + /// The flash the next shot asks for. A lens that can't fire it shoots without. + private(set) var flashMode: AVCaptureDevice.FlashMode = .off + + /// Whether the current lens can fire a flash. + private(set) var isFlashAvailable = false + + /// Whether the session is delivering frames: from its start until ``stop()``. + private(set) var isRunning = false + @ObservationIgnored let session = AVCaptureSession() @ObservationIgnored private let output = AVCapturePhotoOutput() @@ -43,6 +52,11 @@ final class ChatPhotoCamera { @ObservationIgnored private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? // The output holds its delegate weakly; each capture keeps its own alive until it finishes. @ObservationIgnored private var pendingCaptures: [Int64: PhotoCaptureDelegate] = [:] + /// The configuration in flight or done, shared by every caller of ``prepare()``. + @ObservationIgnored private var configuring: Task? + /// Whether the session should be running. A ``stop()`` while ``prepare()`` is still configuring + /// keeps the session from starting once it finishes. + @ObservationIgnored private var wantsRunning = false // Same Swift 6.3 EarlyPerfInliner workaround `CameraSession` carries for @MainActor deinits. nonisolated deinit {} @@ -59,12 +73,71 @@ final class ChatPhotoCamera { } } + /// Returns the flash after `mode` in the toggle: off and on, nothing automatic. + nonisolated static func toggled(_ mode: AVCaptureDevice.FlashMode) -> AVCaptureDevice.FlashMode { + switch mode { + case .off: + .on + case .on, .auto: + .off + @unknown default: + .off + } + } + + /// Returns the flash a shot can ask for given what the output `supported`: `requested` if the + /// output fires it, off otherwise. Asking an output for a mode it lacks raises an exception. + nonisolated static func resolvedFlashMode( + _ requested: AVCaptureDevice.FlashMode, + supported: [AVCaptureDevice.FlashMode] + ) -> AVCaptureDevice.FlashMode { + supported.contains(requested) ? requested : .off + } + + /// Switches the flash between off and on. + func toggleFlash() { + flashMode = Self.toggled(flashMode) + } + // MARK: - Session - - /// Adds the photo output and the current lens to the session; a no-op once configured. - func configure() throws { - guard input == nil else { return } + /// Configures the session with the photo output and the current lens, then starts it, all off the + /// main thread so the first frame can be live before the viewfinder is on screen. Configures once; + /// later calls only start it again. Does not ask for camera access. + func prepare() async throws { + wantsRunning = true + let configuring = configuring ?? Task { try await configureOffMain() } + self.configuring = configuring + do { + try await configuring.value + } catch { + // So a later call, after access is granted, can try again. + self.configuring = nil + throw error + } + guard wantsRunning else { return } + start() + } + + private func configureOffMain() async throws { + let session = session + let output = output + let position = position + let configured = try await withCheckedThrowingContinuation { continuation in + sessionQueue.async { + continuation.resume(with: Result { try Self.configure(session, output: output, position: position) }) + } + } + adopt(configured, at: position) + } + /// Adds `output` and the `position` lens to `session`, returning the lens's input and whether it + /// can fire a flash. Blocking; runs on the session queue. + private nonisolated static func configure( + _ session: AVCaptureSession, + output: AVCapturePhotoOutput, + position: AVCaptureDevice.Position + ) throws -> ConfiguredInput { session.beginConfiguration() // An uncommitted session crashes on stopRunning(), so commit on every exit. defer { session.commitConfiguration() } @@ -74,20 +147,59 @@ final class ChatPhotoCamera { throw SetupError.configurationRejected } session.addOutput(output) - try attachInput(for: position) + return try attach(position, to: session, output: output) + } + + /// Adds the `position` lens to `session`, returning its input and whether `output` can fire a + /// flash through it. Inside a configuration block. + private nonisolated static func attach( + _ position: AVCaptureDevice.Position, + to session: AVCaptureSession, + output: AVCapturePhotoOutput + ) throws -> ConfiguredInput { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) else { + throw SetupError.deviceUnavailable + } + guard + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) + else { + throw SetupError.configurationRejected + } + session.addInput(input) + // Read after the input is attached: the output's supported modes follow the lens. + return ConfiguredInput(input: input, isFlashAvailable: output.supportedFlashModes.contains(.on)) + } + + /// Records the lens `configured` attached at `position`. + private func adopt(_ configured: ConfiguredInput, at position: AVCaptureDevice.Position) { + input = configured.input + self.position = position + isFlashAvailable = configured.isFlashAvailable + // The app is portrait-locked, so the interface can't say how the phone is held; the + // coordinator reads the device's physical orientation for the photo's EXIF. + rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: configured.input.device, previewLayer: nil) } /// Starts the session off the main thread; `startRunning()` blocks until frames flow. - func start() { + private func start() { let session = session - sessionQueue.async { - guard !session.isRunning else { return } - session.startRunning() + sessionQueue.async { [weak self] in + if !session.isRunning { + session.startRunning() + } + let isRunning = session.isRunning + Task { @MainActor [weak self] in + guard let self, self.wantsRunning else { return } + self.isRunning = isRunning + } } } - /// Stops the session off the main thread. + /// Stops the session off the main thread, or keeps one still being prepared from starting. func stop() { + wantsRunning = false + isRunning = false let session = session sessionQueue.async { guard session.isRunning else { return } @@ -118,21 +230,7 @@ final class ChatPhotoCamera { } private func attachInput(for position: AVCaptureDevice.Position) throws { - guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) else { - throw SetupError.deviceUnavailable - } - guard - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) - else { - throw SetupError.configurationRejected - } - session.addInput(input) - self.input = input - self.position = position - // The app is portrait-locked, so the interface can't say how the phone is held; the - // coordinator reads the device's physical orientation for the photo's EXIF. - rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: nil) + adopt(try Self.attach(position, to: session, output: output), at: position) } // MARK: - Capture - @@ -145,6 +243,9 @@ final class ChatPhotoCamera { defer { isCapturing = false } let settings = AVCapturePhotoSettings() + // Resolved at the shot, not when the flash was toggled: a flip since then changes what the + // output supports. + settings.flashMode = Self.resolvedFlashMode(flashMode, supported: output.supportedFlashModes) if let connection = output.connection(with: .video), let rotationCoordinator { let angle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture if connection.isVideoRotationAngleSupported(angle) { @@ -160,12 +261,17 @@ final class ChatPhotoCamera { } pendingCaptures[id] = nil - // `UIImage(data:)` carries the EXIF orientation into `imageOrientation`, which the uploader - // bakes into the pixels before encoding. - return data.flatMap(UIImage.init(data:)) + guard let data else { return nil } + return await ChatPhotoStaging.decode(data) } } +/// The lens a configuration attached, carried back from the session queue. +private nonisolated struct ConfiguredInput { + let input: AVCaptureDeviceInput + let isFlashAvailable: Bool +} + // MARK: - PhotoCaptureDelegate - // AVFoundation calls back on its own queue, so the delegate stays off the main actor, as diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift new file mode 100644 index 000000000..fb5e01187 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift @@ -0,0 +1,67 @@ +// +// ChatPhotoPreloader.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit + +/// Loads the photos picked in the photo card while they are being picked, so each one is in hand by +/// the time Add stages it. +@MainActor +@Observable +final class ChatPhotoPreloader { + + /// The images that have finished loading, by the item that holds them. + private(set) var images: [Item: UIImage] = [:] + + @ObservationIgnored private var tasks: [Item: Task] = [:] + @ObservationIgnored private let load: (Item) async -> UIImage? + + /// Creates a preloader that reads each item's image through `load`. + init(load: @escaping (Item) async -> UIImage?) { + self.load = load + } + + /// The items being loaded or already loaded. + var trackedItems: Set { Set(tasks.keys) } + + /// Starts loading each item in `selection` not yet started, and drops the ones no longer in it. + func update(selection: [Item]) { + let selected = Set(selection) + for item in tasks.keys where !selected.contains(item) { + tasks.removeValue(forKey: item)?.cancel() + images.removeValue(forKey: item) + } + for item in selection where tasks[item] == nil { + let task = Task { [load] in await load(item) } + tasks[item] = task + Task { [weak self] in + let image = await task.value + // Deselected while it loaded, or loaded again under a new task. + guard let self, self.tasks[item] == task, let image else { return } + self.images[item] = image + } + } + } + + /// Returns `item`'s image if it has already loaded. + func loadedImage(for item: Item) -> UIImage? { + images[item] + } + + /// Returns `item`'s image, waiting for a load in flight or starting one if none was. + func image(for item: Item) async -> UIImage? { + if let image = images[item] { return image } + if let task = tasks[item] { return await task.value } + return await load(item) + } + + /// Cancels every load and lets go of every image. + func reset() { + for task in tasks.values { task.cancel() } + tasks = [:] + images = [:] + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift index 40ab29583..afe0f5979 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -33,6 +33,37 @@ enum ChatPhotoStaging { return staged } + /// Stages photos added from the photo card in selection order: the leading run whose images + /// `loaded` already holds at once, and the rest, from the first one still loading, through + /// `load` without blocking the caller. + /// + /// Returns the first chip when it could be staged at once, for the card to shrink into, and the + /// task staging the rest. + static func stageAdded( + _ items: [Item], + into composer: ComposerModel, + uploader: ChatMediaUploader, + loaded: (Item) -> UIImage?, + load: @escaping (Item) async -> UIImage? + ) -> (handOff: ComposerChip.ID?, remainder: Task<[ComposerChip], Never>?) { + var handOff: ComposerChip.ID? + var index = items.startIndex + while index < items.endIndex, let image = loaded(items[index]) { + // The image is its own preview, so the chip has pixels on the hand-off's first frame. + guard let chip = composer.stageChip(image: image, preview: image, uploader: uploader) else { + return (handOff, nil) + } + if handOff == nil { handOff = chip.id } + index += 1 + } + let rest = Array(items[index...]) + guard !rest.isEmpty else { return (handOff, nil) } + let remainder = Task { + await stage(rest, into: composer, uploader: uploader, load: load) + } + return (handOff, remainder) + } + /// Returns the photo `item` holds, or `nil` when it cannot be loaded or decoded. static func loadImage(_ item: PhotosPickerItem) async -> UIImage? { let data: Data @@ -54,9 +85,25 @@ enum ChatPhotoStaging { return image } - /// Decodes off the main actor, since a full-size photo takes a while to parse. + /// Returns the photo in `data` decoded at full size with its EXIF orientation drawn into the + /// pixels, or `nil` when it cannot be decoded. Off the main actor, since a full-size photo takes + /// a while to parse. + /// + /// Upright pixels, not an `imageOrientation`: `UIImageView` draws a turned image by rotating its + /// layer's contents, and that rotation animates with the cell when a sent photo's bubble lands. @concurrent - private nonisolated static func decode(_ data: Data) async -> UIImage? { - UIImage(data: data) + nonisolated static func decode(_ data: Data) async -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + let upright = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max(width, height), + ] as CFDictionary) else { + return nil + } + return UIImage(cgImage: upright) } } diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift new file mode 100644 index 000000000..3f14e2245 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift @@ -0,0 +1,184 @@ +// +// ChatPhotosCard.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import SwiftUI +import FlipcashUI + +/// A photo pick in progress: the selection, in the order it was made, the loader already reading +/// it, and whether the full system picker is up over the card. +/// +/// Held by the bar's model rather than the card, so the inline picker can be mounted ahead of the card +/// and a Back or a close without a choice can clear the pick wherever it is drawn. +@MainActor +@Observable +final class AttachPhotosPick { + + var selection: [PhotosPickerItem] = [] { + didSet { preloader.update(selection: selection) } + } + + /// Whether the full system picker is up as a sheet over the card. + var showsLibrary = false + + private(set) var preloader = AttachPhotosPick.makePreloader() + + /// Discards the selection and what was loaded for it. + func reset() { + selection = [] + showsLibrary = false + preloader.reset() + } + + /// Hands the selection and the loader reading it to `add`, and starts a fresh pick without + /// cancelling what that loader is still reading. + func handOff(to add: ([PhotosPickerItem], ChatPhotoPreloader) -> Void) { + let selection = selection + let preloader = preloader + self.preloader = Self.makePreloader() + self.selection = [] + showsLibrary = false + add(selection, preloader) + } + + private static func makePreloader() -> ChatPhotoPreloader { + ChatPhotoPreloader(load: ChatPhotoStaging.loadImage) + } +} + +/// The attach menu's Photos: the system photo picker inline, with a back chevron and, bottom right, +/// All Photos — the full system picker, for albums — until something is selected, then an Add pill. +/// Content of ``AttachSurface``, which draws the card's glass and clip. +/// +/// The picker runs out of process and needs no photo library permission. Selecting stages nothing, +/// but starts loading each photo, so it is in hand at Add; the selection is handed over then, in the +/// order it was made. +struct ChatPhotosCard: View { + + let pick: AttachPhotosPick + /// The most photos one pick may hold, so the strip never passes `ComposerModel.maxAttachments`. + let selectionLimit: Int + /// Receives the selection, in the order it was made, with the loader already reading it. + let onAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void + /// Fired by the back chevron and the escape gesture; the host discards the selection. + let onBack: () -> Void + /// Fired by All Photos in place of putting the full picker up here, for a card that can't present + /// a sheet. All Photos only shows with nothing selected, so nothing is lost. + var onAllPhotos: (() -> Void)? = nil + /// Whether the full picker comes up as the card appears, for a card All Photos was handed to. + var opensLibraryOnAppear = false + + /// Reports each tap to the selection as it is made, in order. The picker's own Add is disabled, + /// and the non-continuous behaviours report only through it. + static let selectionBehavior: PhotosPickerSelectionBehavior = .continuousAndOrdered + + /// Returns the Add pill's title for `count` selected photos. + static func addTitle(count: Int) -> String { + count == 1 ? "Add 1 photo" : "Add \(count) photos" + } + + var body: some View { + @Bindable var pick = pick + PhotosPicker( + selection: $pick.selection, + maxSelectionCount: selectionLimit, + selectionBehavior: Self.selectionBehavior, + matching: .images, + photoLibrary: .shared() + ) { + Text("Photos") + } + .photosPickerStyle(.inline) + // Hidden at the top too: there it shows a prompt, a Photos/Collections switch, and a privacy + // banner that take most of the card, and nothing can switch it to Collections from outside. + // All Photos opens the full picker for albums instead. + .photosPickerAccessoryVisibility(.hidden, edges: .all) + .photosPickerDisabledCapabilities(.selectionActions) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .bottom) { + controls + } + .accessibilityElement(children: .contain) + .accessibilityAction(.escape, onBack) + .accessibilityIdentifier("attach-photos-card") + // Bound to the same selection, so picks made in either picker show in both. + .photosPicker( + isPresented: $pick.showsLibrary, + selection: $pick.selection, + maxSelectionCount: selectionLimit, + selectionBehavior: Self.selectionBehavior, + matching: .images, + photoLibrary: .shared() + ) + .onAppear { + if opensLibraryOnAppear { + pick.showsLibrary = true + } + } + } + + private var controls: some View { + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .glassBackground(cornerRadius: 22) + .clipShape(Circle()) + .accessibilityLabel("Back to attach menu") + .accessibilityIdentifier("attach-photos-back") + + Spacer() + + // One or the other, cross-faded in place as the first pick arrives and the last leaves. + if pick.selection.isEmpty { + Button { + if let onAllPhotos { + onAllPhotos() + } else { + pick.showsLibrary = true + } + } label: { + Text("All Photos") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 18) + .frame(height: 44) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .glassBackground(cornerRadius: 22) + .clipShape(Capsule()) + .accessibilityIdentifier("attach-photos-all") + .transition(.opacity) + } else { + Button { + pick.handOff(to: onAdd) + } label: { + Text(Self.addTitle(count: pick.selection.count)) + .font(.appTextMedium) + .foregroundStyle(Color.textAction) + .padding(.horizontal, 18) + .frame(height: 44) + .background(Color.action, in: Capsule()) + .contentShape(Capsule()) + .contentTransition(.numericText()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("attach-photos-add") + .transition(.opacity) + } + } + .padding(12) + .animation(ChatMotion.composerChip.animation, value: pick.selection.isEmpty) + .animation(ChatMotion.composerChip.animation, value: pick.selection.count) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 59774e657..754140835 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -120,11 +120,14 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let authorAvatars: [UserID: Data] /// Whether the chat takes photos; false for an E2EE DM. var acceptsMedia: Bool = false - /// Receives a photo taken with the inline camera, which the representable opens in the - /// keyboard's place and closes again once the photo is handed over. - var onCameraCapture: (UIImage) -> Void = { _ in } - /// Receives the photos picked from the attach menu, in the order they were selected. - var onPhotosPicked: ([PhotosPickerItem]) -> Void = { _ in } + /// Receives a photo taken with the camera card, which the representable opens over the composer + /// and closes again once the photo is handed over. + /// Returns the chip the photo was staged as, which the capture shrinks into. + var onCameraCapture: (ChatCameraCapture) -> ComposerChip.ID? = { _ in nil } + /// Receives the photos added from the attach menu's photo card, in the order they were + /// 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 } /// 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 } @@ -290,7 +293,17 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } private func bar(coordinator: Coordinator) -> AnyView { - AnyView( + coordinator.overlayActions = AttachOverlayActions( + onCash: onSendCash, + onCamera: { [weak coordinator] in coordinator?.openCard(.camera) }, + onPhotos: { [weak coordinator] in coordinator?.openCard(.photos) }, + onCameraCapture: { [weak coordinator] capture in coordinator?.closeCard { onCameraCapture(capture) } }, + onCameraCancel: { [model = barModel] in model.returnToMenu() }, + onPhotosAdd: { [weak coordinator] items, preloader in coordinator?.closeCard { onPhotosAdded(items, preloader) } }, + onPhotosBack: { [model = barModel] in model.returnToMenu() }, + onAllPhotos: { [weak coordinator] in coordinator?.overlay.handOffToBar() } + ) + return AnyView( ConversationBottomBar( showsSendCash: showsSendCash, chatExists: chatExists, @@ -308,21 +321,29 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { isJoiningChat: isJoiningChat, mentions: mentions, acceptsMedia: acceptsMedia, - onCamera: { [weak coordinator, camera = barModel.camera] in - coordinator?.screen?.dismissKeyboard() - camera.open() + onAttachOpen: { [weak coordinator] items in + coordinator?.openAttach(items: items) + }, + onCamera: { [weak coordinator] in + coordinator?.openCard(.camera) + }, + onCameraCapture: { [weak coordinator] capture in + coordinator?.closeCard { onCameraCapture(capture) } }, - onCameraCapture: { [weak coordinator, camera = barModel.camera] image in - onCameraCapture(image) - camera.close() - // Back to the keyboard, so the photo can be captioned. - coordinator?.screen?.focusComposer() + onCameraCancel: { [model = barModel] in + // Back into the panel, with the keyboard left down. + model.returnToMenu() }, - onCameraCancel: { [weak coordinator, camera = barModel.camera] in - camera.close() - coordinator?.screen?.focusComposer() + onPhotos: { [weak coordinator] in + coordinator?.openCard(.photos) + }, + onPhotosAdd: { [weak coordinator] items, preloader in + coordinator?.closeCard { onPhotosAdded(items, preloader) } + }, + onPhotosBack: { [model = barModel] in + // Back into the panel, with the keyboard left down. + model.returnToMenu() }, - onPhotosPicked: onPhotosPicked, // Only a member can aim a reply, so the viewer may see what it quotes. quoteThumbnailURL: { [resolver = coordinator.mediaURLResolver] kind in await resolver.thumbnailURL(for: kind, canReact: true) @@ -339,14 +360,77 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } } ) + // Behind the bar and over the transcript, which the bar's host covers while the panel is + // up: a touch anywhere outside the panel takes it down. + .background { + if barModel.attachPanel.isOpen { + AttachPanelDismissArea(panel: barModel.attachPanel) + } + } + .modifier(BarOverflowReporting(model: barModel) { [weak coordinator] in coordinator?.screen }) ) } - func makeCoordinator() -> Coordinator { Coordinator() } + func makeCoordinator() -> Coordinator { Coordinator(model: barModel, composer: composer) } @MainActor final class Coordinator { var barHost: UIHostingController? weak var screen: ChatScreenViewController? + let model: ConversationBarModel + let composer: ComposerModel + /// Draws the attach panel and cards over the keyboard while the composer keeps focus. + let overlay: AttachKeyboardOverlay + /// What the panel and cards drawn over the keyboard hand back, as of the latest render. + var overlayActions: AttachOverlayActions? + + init(model: ConversationBarModel, composer: ComposerModel) { + self.model = model + self.composer = composer + self.overlay = AttachKeyboardOverlay(model: model) + } + + /// `+` is opening the panel with `items` as its rows: over the keyboard while it is up and its + /// window or the field's input view can carry the panel, else with the keyboard taken down. + func openAttach(items: [AttachMenuItem]) { + guard let screen, let overlayActions else { return } + // Sized now, so the card's content is laid out at its final size while the menu is up. + model.attachCard.measure(screenHeight: screen.view.bounds.height) + let mode = overlay.begin(screen: screen, items: items, composer: composer, actions: overlayActions) + switch mode { + case .keyboardWindow, .inputView: + break + case .dismissKeyboard: + screen.dismissKeyboard() + } + } + + /// Puts the camera or photo card up from the panel, over the keyboard if the panel is. + func openCard(_ content: AttachCard.Content) { + if overlay.mode == nil { + screen?.dismissKeyboard() + } + model.attachCard.open(content, screenHeight: screen?.view.bounds.height ?? 0) + } + + /// Takes the card down into the chip `stage` returns, and puts focus back in the field so the + /// photo can be captioned. The chip waits hidden while the bar lays it out, and the surface + /// shrinks onto its frame once it has. + func closeCard(handingOffTo stage: () -> ComposerChip.ID?) { + let card = model.attachCard + guard let chipID = stage() else { + card.animate { card.close() } + screen?.focusComposer() + return + } + card.beginLanding(on: chipID) + screen?.focusComposer() + // A chip the bar never lays out leaves nothing to land on, so the card goes anyway. + DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.attachCard.duration) { + guard card.isOpen, card.landingChipID == chipID else { return } + card.animate({ card.close() }, then: { card.endLanding() }) + } + } + var lastMessageID: String? /// The latest ``ChatScreenRepresentable/mintMediaURL``, read by the resolver. var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } diff --git a/Flipcash/Core/Screens/Conversation/ComposerChip.swift b/Flipcash/Core/Screens/Conversation/ComposerChip.swift index 7deb02c7c..763083366 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -32,6 +32,9 @@ final class ComposerChip: Identifiable { let id = UUID() let image: UIImage + /// A display-ready copy decoded ahead of staging, so the chip can draw on its first frame — the + /// camera's capture shrinks into the chip before its own thumbnail could be decoded. + let preview: UIImage? var state: State = .preparing /// Pixel width of the image as it will be uploaded, known before the upload starts. @@ -48,8 +51,9 @@ final class ComposerChip: Identifiable { /// composer has let go of the chip. @ObservationIgnored private var uploader: ChatMediaUploader? - init(image: UIImage) { + init(image: UIImage, preview: UIImage? = nil) { self.image = image + self.preview = preview } /// Uploads the image through `uploader`, replacing any earlier attempt; calling it again after diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index 476bce9b4..af495806c 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -30,7 +30,7 @@ enum ComposerChipBadge: Equatable { } } -/// The photos staged for the next send, as a row of thumbnails above the message field. +/// The photos staged for the next send, as a row of thumbnails inside the message field, above its text. /// /// Part of the bar's own content rather than a reveal like the reply strip: its arrival changes the /// bar's height the way a draft wrapping to a second line does, which the screen takes in one frame. @@ -40,8 +40,15 @@ struct ComposerChipStrip: View { static let chipSize: CGFloat = 56 let chips: [ComposerChip] + /// The chip the attach surface is shrinking onto, held hidden until it has. + var landingChipID: ComposerChip.ID? = nil + /// Receives the landing chip's frame in window coordinates once it is laid out. + var onLandingChipFrame: (CGRect) -> Void = { _ in } let onRemove: (ComposerChip.ID) -> Void let onRetry: (ComposerChip) -> Void + /// How far the first and last chips sit in from the strip's edges, where the chips scrolling + /// past fade out instead of being cut off. + var edgeInset: CGFloat = 0 /// Returns whether the strip is drawn: while chips are staged, and not during an edit, which /// takes the bar for itself and hands the chips back when it ends. @@ -49,20 +56,50 @@ struct ComposerChipStrip: View { chipCount > 0 && !isEditing } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { + let motion = AttachMotion(reduceMotion: reduceMotion) ScrollView(.horizontal) { HStack(spacing: 8) { ForEach(chips) { chip in + let isLanding = chip.id == landingChipID ComposerChipView( chip: chip, onRemove: { onRemove(chip.id) }, onRetry: { onRetry(chip) } ) + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { frame in + if isLanding { + onLandingChipFrame(frame) + } + } + .opacity(isLanding ? 0 : 1) + .animation(ChatMotion.attachContentIn, value: isLanding) + // Arrives where it lies, so the frame it reports is where the card lands. + .transition(motion.chipTransition(isLanding: isLanding)) + } + } + // A chip arriving or leaving slides its neighbours over on the chip spring, unless the + // change came with an animation of its own. + .transaction(value: chips.map(\.id)) { transaction in + if transaction.animation == nil, !transaction.disablesAnimations { + transaction.animation = ChatMotion.composerChip.animation } } } .scrollIndicators(.hidden) + .contentMargins(.horizontal, edgeInset, for: .scrollContent) .frame(height: Self.chipSize) + .mask { + HStack(spacing: 0) { + LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) + .frame(width: edgeInset) + Color.black + LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) + .frame(width: edgeInset) + } + } .accessibilityIdentifier("composer-chip-strip") } } @@ -82,17 +119,21 @@ private struct ComposerChipView: View { var body: some View { let size = ComposerChipStrip.chipSize - ZStack { - Color.backgroundSecondary - if let thumbnail { - Image(uiImage: thumbnail) - .resizable() - .scaledToFill() + // Sized by the colour alone: a filled photo reports its own aspect's height, and laid out + // beside the colour it stretched the chip past its square and over the row below. + Color.backgroundSecondary + .overlay { + // The capture's preview until the thumbnail is decoded, so a chip shrinking out of the + // camera has its photo from the first frame. + if let image = thumbnail ?? chip.preview { + Image(uiImage: image) + .resizable() + .scaledToFill() + } } - badge - } + .overlay { badge } + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) .overlay(alignment: .topTrailing) { Button(action: onRemove) { Image(systemName: SystemSymbol.closeCircle.rawValue) diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 108d94bbc..a9494076a 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -209,12 +209,13 @@ final class ComposerModel { mode = .new } - /// Stages `image` as a new chip and starts its upload through `uploader`, returning the chip, or - /// returns `nil` when the composer already holds `maxAttachments` chips. + /// Stages `image` as a new chip, drawn from `preview` until its thumbnail is ready, and starts its + /// upload through `uploader`, returning the chip, or returns `nil` when the composer already + /// holds `maxAttachments` chips. @discardableResult - func stageChip(image: UIImage, uploader: ChatMediaUploader) -> ComposerChip? { + func stageChip(image: UIImage, preview: UIImage? = nil, uploader: ChatMediaUploader) -> ComposerChip? { guard chips.count < Self.maxAttachments else { return nil } - let chip = ComposerChip(image: image) + let chip = ComposerChip(image: image, preview: preview) chips.append(chip) chip.startUpload(using: uploader) return chip diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 9d8725ccf..ef3b64b3a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -15,8 +15,129 @@ import FlipcashUI /// `ComposerModel`, which also knows whether it is a new message or an edit. @MainActor @Observable final class ConversationBarModel { var isComposing = false - /// The inline camera, which takes the keyboard's place under the composer. - let camera = ChatCameraSlot() + /// The camera or photo card the attach panel expands into. + let attachCard = AttachCard() + /// The attach menu's floating panel, grown out of `+`. + let attachPanel = AttachPanel() + /// Gets the camera running ahead of the camera card. + let attachWarmUp = AttachWarmUp() + /// The photo card's pick, kept across Back so its picker is mounted once per panel. + let photosPick = AttachPhotosPick() + /// Whether the panel and cards are drawn over the keyboard instead of in the bar. + let overKeyboard = AttachOverKeyboard() + + /// The card's top edge in window coordinates, as last laid out. + var cardTop: CGFloat = 0 + + /// What the attach surface is showing. + var attachSurfacePhase: AttachSurfacePhase { + AttachSurfacePhase( + isMenuOpen: attachPanel.isOpen, + card: attachCard.content, + isLanding: attachCard.landingChipID != nil + ) + } + + /// Whether the attach surface is on screen: from the panel opening until the last exit, a card's + /// or a landing's, has run. + var attachSurfaceIsMounted: Bool { + attachPanel.holdsOverflow || attachCard.holdsOverflow || attachCard.landingChipID != nil + } + + /// Whether the surface is drawn where `+` stands, as `+`: from the panel opening until it has + /// collapsed back, except while it shrinks onto a chip and leaves `+` to show again. + var surfaceStandsInForPlus: Bool { + attachSurfaceIsMounted && attachSurfacePhase != .landing + } + + /// What the screen has to give the bar above its own frame for the panel and the card. + var overflow: BarOverflow { + guard attachPanel.holdsOverflow || attachCard.holdsOverflow else { return .none } + // A panel or card over the keyboard draws in the keyboard's window and takes its own + // touches there. + if overKeyboard.isActive { return .none } + if attachPanel.isOpen { return .everywhere } + // A leaving card takes no touches: the band closes to the bar's own frame. + return .band(top: attachCard.isOpen ? cardTop : .greatestFiniteMagnitude) + } + + /// Takes the camera or photo card back into the attach panel, discarding any pick: one + /// transaction, so the surface springs from the card's frame to the menu's. + func returnToMenu() { + guard attachCard.isOpen else { return } + photosPick.reset() + withAnimation(ChatMotion.attachCard.animation, completionCriteria: .logicallyComplete) { + attachCard.close() + attachPanel.open() + } completion: { [weak self] in + self?.attachCard.exitDidFinish() + } + } + + /// Takes the panel and the card down at once, with nothing to animate out: the keyboard they were + /// drawn over went, or the app left the foreground. + func resetAttach() { + attachPanel.dismiss() + attachPanel.exitDidFinish() + attachCard.close() + attachCard.exitDidFinish() + attachCard.endLanding() + photosPick.reset() + } + + /// The landing chip was laid out at `frame`, in window coordinates: the card shrinks onto it the + /// first time, and follows it on the card's spring if it moves while the card is shrinking. + func landingChipDidLayout(_ frame: CGRect) { + let card = attachCard + let wasPlaced = card.landingChipFrame != nil + guard !wasPlaced else { + withAnimation(ChatMotion.attachCard.animation) { _ = card.landingChipDidLayout(frame) } + return + } + guard card.landingChipDidLayout(frame) else { return } + card.animate({ card.close() }, then: { card.endLanding() }) + } +} + +/// How the bar reaches above its own frame: not at all, everywhere for a panel that dismisses on any +/// outside touch, or from a window-space `top` down for a card that lets touches above it through to +/// the transcript. +enum BarOverflow: Equatable { + case none + case everywhere + case band(top: CGFloat) + + /// Whether the bar draws above its own frame. + var overflows: Bool { + switch self { + case .none: false + case .everywhere, .band: true + } + } + + /// Where, in window coordinates, the bar starts taking touches, or `nil` for everywhere it reaches. + var touchTop: CGFloat? { + switch self { + case .none: .greatestFiniteMagnitude + case .everywhere: nil + case .band(let top): top + } + } +} + +/// Hands the bar's ``BarOverflow`` to the screen hosting it whenever it changes. +struct BarOverflowReporting: ViewModifier { + let model: ConversationBarModel + let screen: () -> ChatScreenViewController? + + func body(content: Content) -> some View { + content.onChange(of: model.overflow) { _, overflow in + guard let screen = screen() else { return } + // The band first, so the bar never overflows with the last one's touches. + screen.barOverflowTouchTop = overflow.touchTop + screen.barOverflowsTop = overflow.overflows + } + } } /// Single spring driving the whole bar: the button morph, the composer's @@ -122,14 +243,22 @@ struct ConversationBottomBar: View { @State private var composerRowHeight: CGFloat = 0 /// Whether the chat takes photos; false for an E2EE DM, whose encryption does not cover media. var acceptsMedia: Bool = false + /// Fired as `+` opens the attach panel with the given rows: the panel goes up over the keyboard, + /// or the keyboard goes down under it. + var onAttachOpen: ([AttachMenuItem]) -> Void = { _ in } /// Opens the camera from the attach menu. var onCamera: () -> Void = {} /// Receives a photo the inline camera took. - var onCameraCapture: (UIImage) -> Void = { _ in } - /// Fired by the inline camera's back chevron. + var onCameraCapture: (ChatCameraCapture) -> Void = { _ in } + /// Fired by the camera card's back chevron. var onCameraCancel: () -> Void = {} - /// Receives the photos picked from the attach menu, in the order they were selected. - var onPhotosPicked: ([PhotosPickerItem]) -> Void = { _ in } + /// Opens the photo card from the attach menu. + var onPhotos: () -> Void = {} + /// 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 } + /// 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. var quoteThumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } @@ -144,6 +273,8 @@ struct ConversationBottomBar: View { /// How much further in than ``BarMetrics/edgeInset`` the controls and the reply quote sit. private var compactExtraInset: CGFloat { isCompact ? BarMetrics.compactInset - BarMetrics.edgeInset : 0 } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { // The gate panel takes the bar whole rather than sitting inside it: none of the composer's // springs key on state a gated user can change. @@ -174,21 +305,15 @@ struct ConversationBottomBar: View { // gained or lost. Nothing animates that travel — the bar's springs key on `chatExists` and // `isEditing`, neither of which moves during a send — so it snapped while the bar's height // sprang underneath it. + let motion = AttachMotion(reduceMotion: reduceMotion) let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { - // Below the reply strip and above the field, as the spec orders them. - if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { - ComposerChipStrip( - chips: composer.chips, - onRemove: { composer.removeChip($0) }, - onRetry: { $0.retryUpload() } - ) - } HStack(alignment: .bottom, spacing: 10) { // An edit takes over the bar: the leading control becomes the way out of it and the // attach menu steps aside until it resolves, the way WhatsApp hides its accessory controls. switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } + .hiddenUnderAttachCard(showsCard) case .sendCash: SendCashMorphButton( symbol: symbol, @@ -205,28 +330,39 @@ struct ConversationBottomBar: View { expandedTitle: isTipDm ? startChattingTitle : nil, action: onSendCash ) + .hiddenUnderAttachCard(showsCard) case .attach(let items): AttachMenu( items: items, - photosSelectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), - onCash: onSendCash, - onCamera: onCamera, - onPhotos: onPhotosPicked + panel: model.attachPanel, + hidesButton: showsCard, + // Swapped out in one frame for the surface, which is drawn as `+` where it stands. + isStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onOpen: onAttachOpen, + onPlusFrame: { model.overKeyboard.plusFrame = $0 } ) + // Over the field beside it, which its panel floats across. + .zIndex(1) case .none: EmptyView() } if chatExists { ConversationComposer(conversationID: conversationID, model: model, composer: composer) + .hiddenUnderAttachCard(showsCard) .transition(.opacity) } } - // In the keyboard's place: the screen lowers the keyboard as this opens, and the bar - // grows by what the keyboard held, so the field stays where it was. - if model.camera.isOpen { - ChatCameraSheet(onCapture: onCameraCapture, onCancel: onCameraCancel) - .frame(height: max(0, model.camera.height - Self.rowSpacing)) - .transition(.opacity) + // Kept mounted under the card, so `+` is there for the surface to shrink back into and the + // field is there to take focus the moment the card closes. Each control fades on its own + // rather than the row, which holds the surface. An open panel lets a touch on the row — + // send included — fall through to the dismiss area behind the bar, so it only closes the panel. + .allowsHitTesting(!showsCard && !model.attachPanel.isOpen) + .accessibilityHidden(showsCard) + // After the row's own opacity, so the surface does not fade with it. + .overlay(alignment: .topLeading) { + if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, case .attach(let items) = leadingControl { + attachSurface(items: items) + } } } // The Send Cash button morphs on the reply's spring. Inside the row's padding, so it covers @@ -240,7 +376,46 @@ struct ConversationBottomBar: View { .padding(.bottom, BarMetrics.contentPadding) .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) - .animation(barMorphSpring, value: model.camera.isOpen) + // 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. + .transaction(value: ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing)) { transaction in + if transaction.animation == nil, !transaction.disablesAnimations { + transaction.animation = ChatMotion.composerChip.animation + } + } + .onChange(of: wantsCameraWarm) { _, wanted in + model.attachWarmUp.update(wanted: wanted) + } + // A panel closed without a choice leaves no pick behind for the next one. + .onChange(of: model.attachSurfaceIsMounted) { _, isMounted in + if !isMounted { + model.photosPick.reset() + } + } + .onDisappear { + model.attachWarmUp.update(wanted: false) + } + // Typing takes the attach panel down, as a tap outside it does. + .onChange(of: composer.draft) { + guard model.attachPanel.isOpen else { return } + model.attachPanel.animate { $0.dismiss() } + } + // An edit takes `+`'s slot, and the panel or card with it, so Back has no panel to return to. + .onChange(of: composer.isEditing) { _, isEditing in + guard isEditing else { return } + if model.attachCard.isOpen { + model.attachCard.close() + model.attachCard.exitDidFinish() + } + if model.attachPanel.isOpen { + model.attachPanel.animate { $0.dismiss() } + } + } + // So does focusing the field, whose send-button end the panel leaves uncovered. + .onChange(of: model.isComposing) { _, isComposing in + guard isComposing, model.attachPanel.isOpen else { return } + model.attachPanel.animate { $0.dismiss() } + } // No shared GlassEffectContainer: the composer's glass is a background // layer behind an editable text field, and a container composites its @@ -328,10 +503,63 @@ struct ConversationBottomBar: View { return listedCandidates } - /// The gap between the bar's rows. The camera's height gives it back, so the bar grows by - /// exactly the keyboard's height. + /// The gap between the bar's rows. private static let rowSpacing: CGFloat = 8 + /// Whether the camera or photo card is up over the composer row. + private var showsCard: Bool { + model.attachCard.isOpen + } + + /// The attach surface in the bar: the menu standing on `+`, and the card standing on the composer + /// row's bottom edge, a fixed share of the screen tall and as wide as the bar's controls are with + /// the keyboard up. An overlay, so the bar's measured height — and with it the transcript's inset + /// — stays the composer's. + private func attachSurface(items: [AttachMenuItem]) -> some View { + GeometryReader { proxy in + let origin = proxy.frame(in: .global).origin + let toRow: (CGRect) -> CGRect = { $0.offsetBy(dx: -origin.x, dy: -origin.y) } + AttachSurface( + model: model, + items: items, + plus: toRow(model.overKeyboard.plusFrame), + card: AttachSurfaceLayout.barCardRect(row: proxy.size, outset: compactExtraInset, height: model.attachCard.height), + landing: model.attachCard.landingChipFrame.map(toRow), + menuPlacement: .standsOnPlus, + selectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), + actions: AttachOverlayActions( + onCash: onSendCash, + onCamera: onCamera, + onPhotos: onPhotos, + onCameraCapture: onCameraCapture, + onCameraCancel: onCameraCancel, + onPhotosAdd: onPhotosAdd, + onPhotosBack: onPhotosBack + ), + opensLibraryOnAppear: model.overKeyboard.opensLibraryInBar + ) { region, rect in + if region == .card, let rect { + model.cardTop = rect.minY + origin.y + } + } + .onAppear { + // A turn later, so the card has read it first. + DispatchQueue.main.async { model.overKeyboard.opensLibraryInBar = false } + } + } + } + + /// Whether the camera should be warm: while the open panel offers it, or its card shows it. + private var wantsCameraWarm: Bool { + let showsCamera = model.attachCard.content == .camera + switch leadingControl { + case .attach(let items): + return showsCamera || (model.attachPanel.isOpen && items.contains(.camera)) + case .cancelEdit, .sendCash, .none: + return showsCamera + } + } + private var leadingControl: ConversationBarLeadingControl { ConversationBarLeadingControl( isEditing: composer.isEditing, @@ -716,9 +944,14 @@ struct ConversationComposer: View { /// Send button scale-in/out as text appears/clears. private static let sendButtonSpring = ChatMotion.sendButton.animation + private static let leadingInset: CGFloat = 14 + private static let trailingInset: CGFloat = 8 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - let field = HStack(alignment: .bottom, spacing: 10) { + let motion = AttachMotion(reduceMotion: reduceMotion) + let textRow = HStack(alignment: .bottom, spacing: 10) { TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) .font(.appTextMessage) .foregroundStyle(Color.textMain) @@ -761,9 +994,29 @@ struct ConversationComposer: View { .animation(Self.sendButtonSpring, value: showsSubmit) } + // The staged photos ride inside the field, above the text, so the field reads as one message. + let field = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { + if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { + ComposerChipStrip( + chips: composer.chips, + landingChipID: model.attachCard.landingChipID, + onLandingChipFrame: { model.landingChipDidLayout($0) }, + onRemove: { composer.removeChip($0) }, + onRetry: { $0.retryUpload() }, + edgeInset: Self.leadingInset + ) + // Out to the field's own edges, so chips scroll under a fade rather than a hard margin. + .padding(.leading, -Self.leadingInset) + .padding(.trailing, -Self.trailingInset) + .padding(.top, BarMetrics.fieldVerticalPadding / 2) + .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) + } + textRow + } + return field - .padding(.leading, 14) - .padding(.trailing, 8) + .padding(.leading, Self.leadingInset) + .padding(.trailing, Self.trailingInset) .padding(.vertical, BarMetrics.fieldVerticalPadding) // Glass *behind* the field, not wrapping it: wrapping an editable // TextField in `glassEffect` reparents its text view into the glass @@ -803,6 +1056,10 @@ struct ConversationComposer: View { private func submit() { guard let conversationID else { return } + // A photo-only send leaves the draft as it was, so the draft's own dismissal never fires. + if model.attachPanel.isOpen { + model.attachPanel.animate { $0.dismiss() } + } // Fire-and-forget in both branches: the change applies optimistically and resolves on its own, // so the composer stays ready immediately. Emptying the field up front makes a double-tap a diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index c693a4b17..9d3ee13c3 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -540,19 +540,19 @@ struct ConversationScreen: View { authorAvatars: authorAvatars, acceptsMedia: acceptsMedia, onCameraCapture: stageCapturedPhoto, - onPhotosPicked: stagePickedPhotos, + onPhotosAdded: stageAddedPhotos, mintMediaURL: mintMediaURL, onMediaTap: openMediaViewer ) } - /// Whether the chat takes photos: any chat this device has a record of, except an E2EE DM, whose + /// Whether the chat takes photos: any chat this device has a record of, except one it encrypts, whose /// encryption does not cover media. False until the record loads, so the menu never offers a /// photo the chat may refuse. private var acceptsMedia: Bool { guard let conversationID, let conversation = conversationController.conversation(withID: conversationID) else { return false } - return !conversation.useE2Ee + return !E2eePolicy.shouldEncrypt(conversation) } /// Uploads chat photos on behalf of the signed-in owner. @@ -560,17 +560,24 @@ struct ConversationScreen: View { ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) } - /// Stages photos picked from the library, in the order they were picked. - private func stagePickedPhotos(_ items: [PhotosPickerItem]) { - let uploader = mediaUploader - Task { - await ChatPhotoStaging.stage(items, into: composer, uploader: uploader, load: ChatPhotoStaging.loadImage) - } - } - - /// Stages a photo taken with the inline camera. - private func stageCapturedPhoto(_ image: UIImage) { - composer.stageChip(image: image, uploader: mediaUploader) + /// Stages photos added from the photo card in the order they were selected, returning the chip + /// the first became when its image was already loaded. The rest stage as they finish loading. + private func stageAddedPhotos( + _ items: [PhotosPickerItem], + preloader: ChatPhotoPreloader + ) -> ComposerChip.ID? { + ChatPhotoStaging.stageAdded( + items, + into: composer, + uploader: mediaUploader, + loaded: preloader.loadedImage(for:), + load: preloader.image(for:) + ).handOff + } + + /// 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 } /// Opens a tapped photo full screen, zooming out of its row, with share through the system sheet. diff --git a/Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift b/Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift new file mode 100644 index 000000000..e55d66e9a --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift @@ -0,0 +1,49 @@ +// +// KeyboardOverlayHost.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit + +/// Finds the window the system keyboard is drawn in, so a view can be laid over the live keys. +@MainActor +protocol KeyboardWindowLocating { + /// Returns the window holding the on-screen keyboard, or `nil` when there is none to be found. + func keyboardWindow() -> UIWindow? +} + +/// Finds the system keyboard's window through private UIKit API. The only place in the app that does. +/// +/// Private-API reliant: the software keyboard is drawn in-process, in a window UIKit keeps at a level +/// above every window an app may create (app windows are capped at 10,000,000) and leaves out of +/// `UIApplication.windows` and every scene's `windows`. The one way to reach it is the private class +/// method `+[UIWindow allWindowsIncludingInternalWindows:onlyVisibleWindows:]`, called here through +/// `NSSelectorFromString` after a `responds(to:)` check. A subview added to that window draws over +/// the keys and a glass effect in it blurs them, while the composer stays first responder. +/// +/// Every step is checked: a missing selector, an unexpected return type, or no keyboard window +/// returns `nil`, and the caller falls back to public API. It never traps. +@MainActor +struct KeyboardOverlayHost: KeyboardWindowLocating { + + /// The private selector's name, assembled so the whole name never appears as one literal. + private static let selectorName = ["allWindows", "Including", "Internal", "Windows:", "only", "Visible", "Windows:"].joined() + + /// A fragment of the keyboard window's class name, which no app window carries. + private static let windowClassFragment = ["Remote", "Keyboard", "Window"].joined() + + func keyboardWindow() -> UIWindow? { + let selector = NSSelectorFromString(Self.selectorName) + guard UIWindow.responds(to: selector) else { return nil } + typealias AllWindows = @convention(c) (AnyObject, Selector, Bool, Bool) -> NSArray? + let implementation = UIWindow.method(for: selector) + guard implementation != nil else { return nil } + let allWindows = unsafeBitCast(implementation, to: AllWindows.self) + guard let windows = allWindows(UIWindow.self, selector, true, false) as? [UIWindow] else { return nil } + return windows + .filter { !$0.isHidden && NSStringFromClass(type(of: $0)).contains(Self.windowClassFragment) } + .max { $0.windowLevel.rawValue < $1.windowLevel.rawValue } + } +} diff --git a/FlipcashTests/Chat/AttachOverlayModeTests.swift b/FlipcashTests/Chat/AttachOverlayModeTests.swift new file mode 100644 index 000000000..61012b968 --- /dev/null +++ b/FlipcashTests/Chat/AttachOverlayModeTests.swift @@ -0,0 +1,179 @@ +// +// AttachOverlayModeTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +@testable import Flipcash + +@MainActor +@Suite("Attach overlay mode") +struct AttachOverlayModeTests { + + /// A locator that finds `window`, or nothing, and counts how often it was asked. + private final class StubLocator: KeyboardWindowLocating { + let window: UIWindow? + private(set) var lookups = 0 + + init(window: UIWindow?) { + self.window = window + } + + func keyboardWindow() -> UIWindow? { + lookups += 1 + return window + } + } + + private static let keyboardHeight: CGFloat = 336 + + @Test("A found keyboard window draws the panel over the live keys") + func keyboardWindowWins() { + let window = UIWindow(frame: .zero) + let plan = AttachOverlayPlan( + locator: StubLocator(window: window), + keyboardHeight: Self.keyboardHeight, + canReplaceInputView: true + ) + #expect(plan.mode == .keyboardWindow) + #expect(plan.keyboardWindow === window) + } + + @Test("No keyboard window falls back to swapping the field's input view") + func missingWindowFallsBackToInputView() { + let plan = AttachOverlayPlan( + locator: StubLocator(window: nil), + keyboardHeight: Self.keyboardHeight, + canReplaceInputView: true + ) + #expect(plan.mode == .inputView) + #expect(plan.keyboardWindow == nil) + } + + @Test("No keyboard window and no swappable field takes the keyboard down, as before") + func nothingLeftDismissesTheKeyboard() { + let plan = AttachOverlayPlan( + locator: StubLocator(window: nil), + keyboardHeight: Self.keyboardHeight, + canReplaceInputView: false + ) + #expect(plan.mode == .dismissKeyboard) + #expect(!plan.mode.drawsOverKeyboard) + } + + @Test("With the keyboard down the private lookup is never made", arguments: [CGFloat(0), 44]) + func keyboardDownSkipsTheLookup(height: CGFloat) { + let locator = StubLocator(window: UIWindow(frame: .zero)) + let plan = AttachOverlayPlan(locator: locator, keyboardHeight: height, canReplaceInputView: true) + #expect(plan.mode == .dismissKeyboard) + #expect(plan.keyboardWindow == nil) + #expect(locator.lookups == 0) + } + + @Test("Only the software keyboard's height counts as a keyboard to draw over") + func selectionThresholds() { + let minimum = AttachOverlayMode.minimumKeyboardHeight + #expect(AttachOverlayMode.select(keyboardHeight: minimum - 1, hasKeyboardWindow: true, canReplaceInputView: true) == .dismissKeyboard) + #expect(AttachOverlayMode.select(keyboardHeight: minimum, hasKeyboardWindow: true, canReplaceInputView: false) == .keyboardWindow) + #expect(AttachOverlayMode.select(keyboardHeight: minimum, hasKeyboardWindow: false, canReplaceInputView: true) == .inputView) + } + + @Test("The panel straddles the composer's bottom edge and grows out of +") + func panelStraddlesTheComposerEdge() { + let plus = CGRect(x: 12, y: 500, width: 50, height: 50) + let size = CGSize(width: 240, height: 144) + let frame = AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) + #expect(frame.minX == plus.minX) + #expect(frame.midY == plus.maxY) + + let anchor = AttachOverlayLayout.panelAnchor(plusFrame: plus, size: size) + #expect(frame.minX + anchor.x * size.width == plus.midX) + #expect(frame.minY + anchor.y * size.height == plus.midY) + } + + @Test("The card stands on the screen's bottom, inset from its sides") + func cardStandsOnTheScreenBottom() { + let bounds = CGRect(x: 0, y: 0, width: 402, height: 874) + let frame = AttachOverlayLayout.cardFrame(in: bounds, height: 507) + #expect(frame.maxY == bounds.maxY - AttachOverlayLayout.cardInset) + #expect(frame.minX == AttachOverlayLayout.cardInset) + #expect(frame.width == bounds.width - AttachOverlayLayout.cardInset * 2) + #expect(frame.height == 507) + } + + @Test("The live host returns a window or nil, and never traps") + func liveHostIsSafe() { + // No keyboard is up in the test host, so there is nothing to find; the call itself must hold. + _ = KeyboardOverlayHost().keyboardWindow() + } + + @Test("A card drawn over the keyboard stays up as the keyboard announces itself") + func cardIgnoresKeyboardShowOverKeyboard() { + let card = AttachCard() + card.open(.photos, screenHeight: 800) + card.closesOnKeyboardShow = false + card.keyboardWillShow() + #expect(card.isOpen) + + card.closesOnKeyboardShow = true + card.keyboardWillShow() + #expect(!card.isOpen) + } + + @Test("A card over the keyboard asks the bar for no room, since it takes its own touches") + func overflowStaysOffForCardOverKeyboard() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 800) + #expect(model.overflow != .none) + + model.overKeyboard.activate(items: [.camera, .photos]) + #expect(model.overflow == .none) + } + + @Test("The landing chip's frame is kept only while a landing is under way") + func landingTracksOnlyItsChip() { + let card = AttachCard() + let frame = CGRect(x: 20, y: 600, width: 64, height: 64) + + card.landingChipDidLayout(frame) + #expect(card.landingChipFrame == nil) + + card.beginLanding(on: UUID()) + card.landingChipDidLayout(.zero) + #expect(card.landingChipFrame == nil, "An unlaid-out chip has no frame to land on") + card.landingChipDidLayout(frame) + #expect(card.landingChipFrame == frame) + + card.endLanding() + #expect(card.landingChipID == nil && card.landingChipFrame == nil) + } + + // MARK: - Touches outside the panel + + @Test("A touch outside the panel is taken while the menu dismisses on it") + func outsideTouch_takenWhenDismissing() { + let container = AttachOverlayContainer(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + container.hitRects[.panel] = CGRect(x: 10, y: 500, width: 200, height: 200) + var dismisses = true + container.dismissesOnOutsideTouch = { dismisses } + + #expect(container.hitTest(CGPoint(x: 300, y: 750), with: nil) === container) + + dismisses = false + #expect(container.hitTest(CGPoint(x: 300, y: 750), with: nil) == nil) + } + + @Test("A touch outside the panel fires the dismiss") + func outsideTouch_firesDismiss() { + let container = AttachOverlayContainer(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + var fired = 0 + container.onOutsideTouch = { fired += 1 } + + container.touchesBegan([], with: nil) + + #expect(fired == 1) + } +} diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift new file mode 100644 index 000000000..ab69196ff --- /dev/null +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -0,0 +1,233 @@ +// +// AttachPanelPlacementTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashStore +import FlipcashUI +@testable import Flipcash + +/// The real bar, hosted the way the chat screen hosts it, on a live window. +@MainActor +struct AttachPanelHost { + + let window: UIWindow + let screen: ChatScreenViewController + let barHost: UIHostingController + let model: ConversationBarModel + let composer: ComposerModel + private let screenBox: ScreenBox + private let databaseURL: URL + + init(windowSize: CGSize = CGSize(width: 402, height: 874)) throws { + let (database, url) = try Database.makeTemp() + databaseURL = url + let controller = ConversationController( + fetching: MockConversations(), membership: MockConversations(), viewerSettings: MockConversations(), + messaging: MockConversations(), streaming: MockConversations(), + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: UUID(), + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + let model = ConversationBarModel() + let composer = ComposerModel() + self.model = model + self.composer = composer + + let screenBox = ScreenBox() + self.screenBox = screenBox + let root = HostedBar(controller: controller, model: model, composer: composer, screenBox: screenBox) + + barHost = UIHostingController(rootView: AnyView(root)) + barHost.view.backgroundColor = .clear + barHost.view.clipsToBounds = false + screen = ChatScreenViewController(bar: barHost.view, barController: barHost) + screenBox.screen = screen + + // On the host app's scene, so the window is actually on screen and SwiftUI lays the bar out. + if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first { + window = UIWindow(windowScene: scene) + window.frame = CGRect(origin: .zero, size: windowSize) + } else { + window = UIWindow(frame: CGRect(origin: .zero, size: windowSize)) + } + window.windowLevel = .alert + 1 + window.rootViewController = screen + window.makeKeyAndVisible() + screen.loadViewIfNeeded() + window.layoutIfNeeded() + } + + /// Lets SwiftUI and UIKit settle a few turns. + func settle() async { + for _ in 0..<8 { + window.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(60)) + } + } + + /// The bar's measured height, as last reported to the screen. + var barHeight: CGFloat { screenBox.barHeight } + + /// The window-space point `distance` above the bar's top edge, over `+`'s column. + func pointAboveBar(by distance: CGFloat) -> CGPoint { + CGPoint(x: 40, y: window.bounds.height - barHeight - distance) + } + + /// The window's rendered colour at `point`. + func renderedColor(at point: CGPoint, backdrop: UIColor) -> UIColor { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { ctx in + backdrop.setFill() + ctx.fill(window.bounds) + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let cg = image.cgImage! + var pixel = [UInt8](repeating: 0, count: 4) + let context = CGContext( + data: &pixel, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.draw(cg, in: CGRect(x: -point.x, y: point.y - CGFloat(cg.height) + 1, width: CGFloat(cg.width), height: CGFloat(cg.height))) + return UIColor(red: CGFloat(pixel[0]) / 255, green: CGFloat(pixel[1]) / 255, blue: CGFloat(pixel[2]) / 255, alpha: 1) + } + + func tearDown() { + window.isHidden = true + Database.removeTemp(at: databaseURL) + } + + fileprivate final class ScreenBox { + weak var screen: ChatScreenViewController? + var barHeight: CGFloat = 0 + } +} + +/// The bar as `ChatScreenRepresentable` hosts it: measured, bottom-pinned, and lifting the screen's +/// clip while the panel holds the room above it. +private struct HostedBar: View { + + let controller: ConversationController + let model: ConversationBarModel + let composer: ComposerModel + let screenBox: AttachPanelHost.ScreenBox + + var body: some View { + ConversationBottomBar( + showsSendCash: true, + chatExists: true, + conversationID: .test(1), + symbol: "$", + onSendCash: {}, + model: model, + composer: composer, + acceptsMedia: true + ) + .environment(controller) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in + screenBox.barHeight = height + screenBox.screen?.setBarHeight(height, accessories: BarAccessories()) + } + .frame(maxHeight: .infinity, alignment: .bottom) + .modifier(BarOverflowReporting(model: model) { [screenBox] in screenBox.screen }) + } +} + +@MainActor +@Suite("Attach panel placement") +struct AttachPanelPlacementTests { + + @Test("The open panel floats above the bar, over the transcript, and takes taps there") + func openPanel_sitsAbovePlus() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + host.model.attachPanel.open() + await host.settle() + + // Inside the panel's rows, above the bar: before the fix the panel hung down from `+`'s top + // and nothing of the bar reached here. + let point = host.pointAboveBar(by: 40) + let transcript = host.renderedColor(at: CGPoint(x: 40, y: 100), backdrop: .black) + let probe = host.renderedColor(at: point, backdrop: .black) + #expect(!probe.isClose(to: transcript), "Panel is not drawn above the bar") + + // The screen lifts its clip for the panel, so taps there reach the bar rather than the transcript. + let hit = try #require(host.window.hitTest(point, with: nil)) + #expect(hit.isDescendant(of: host.barHost.view), "Taps above the bar miss the panel") + } + + @Test("The card stands on the composer over the transcript, takes taps on itself, and lets taps above it through") + func card_overlaysTranscript() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + let barHeight = host.barHeight + + // Photos, not the camera, which would put up the system's permission prompt in the test host. + host.model.attachCard.open(.photos, screenHeight: host.window.bounds.height) + await host.settle() + + // The card is an overlay: the bar, and so the transcript's inset, keeps the composer's height. + #expect(host.barHeight == barHeight, "The card resized the bar") + + let cardTop = host.model.cardTop + let cardBottom = cardTop + host.model.attachCard.height + let barTop = host.window.bounds.height - barHeight + #expect(cardBottom > barTop + 20 && cardBottom <= host.window.bounds.height, "The card does not stand on the composer row") + + let onCard = CGPoint(x: host.window.bounds.midX, y: cardTop + 60) + let onCardHit = try #require(host.window.hitTest(onCard, with: nil)) + #expect(onCardHit.isDescendant(of: host.barHost.view), "Taps on the card miss it") + + let aboveCard = CGPoint(x: host.window.bounds.midX, y: cardTop - 40) + let aboveHit = host.window.hitTest(aboveCard, with: nil) + #expect(aboveHit.map { !$0.isDescendant(of: host.barHost.view) } ?? true, "The bar swallows taps above the card") + } + + @Test("Adding from the card shrinks the surface onto the staged chip, which shows once it has landed") + func card_landsOnChip() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + host.model.attachCard.open(.photos, screenHeight: host.window.bounds.height) + await host.settle() + + let red = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 40, height: 40)) + } + let chipID = try #require( + host.composer.stageChip(image: red, preview: red, uploader: ChatMediaUploader(blob: MockChatMediaBlobStore()))?.id + ) + host.model.attachCard.beginLanding(on: chipID) + + // The strip lays the chip out, the bar reports it, and the card shrinks onto it. + await host.settle() + #expect(!host.model.attachCard.isOpen, "The card never shrank onto the chip") + await host.settle() + #expect(host.model.attachCard.landingChipID == nil, "The landing never finished") + #expect(host.composer.chips.count == 1) + } +} + +private extension UIColor { + + func isClose(to other: UIColor, tolerance: CGFloat = 0.02) -> Bool { + var (r1, g1, b1, a1): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + var (r2, g2, b2, a2): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + other.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + return abs(r1 - r2) < tolerance && abs(g1 - g2) < tolerance && abs(b1 - b2) < tolerance + } +} diff --git a/FlipcashTests/Chat/AttachPanelTests.swift b/FlipcashTests/Chat/AttachPanelTests.swift new file mode 100644 index 000000000..72c8bf029 --- /dev/null +++ b/FlipcashTests/Chat/AttachPanelTests.swift @@ -0,0 +1,282 @@ +// +// AttachPanelTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("Attach panel") +struct AttachPanelTests { + + @Test("A fresh panel is closed and asks the bar for no room above it") + func freshPanelIsClosed() { + let panel = AttachPanel() + #expect(!panel.isOpen) + #expect(!panel.holdsOverflow) + } + + @Test("Tapping + opens the panel and a second tap collapses it back") + func toggleOpensAndCollapses() { + let panel = AttachPanel() + panel.toggle() + #expect(panel.isOpen) + #expect(panel.holdsOverflow) + + panel.toggle() + #expect(!panel.isOpen) + } + + @Test("Dismissing keeps the room above the bar until the collapse has finished") + func overflowOutlivesTheCollapse() { + let panel = AttachPanel() + panel.open() + panel.dismiss() + #expect(!panel.isOpen) + #expect(panel.holdsOverflow) + + panel.exitDidFinish() + #expect(!panel.holdsOverflow) + } + + @Test("A collapse that finishes after the panel reopened keeps the room") + func staleExitLeavesOpenPanelAlone() { + let panel = AttachPanel() + panel.open() + panel.dismiss() + panel.open() + + panel.exitDidFinish() + #expect(panel.isOpen) + #expect(panel.holdsOverflow) + } + + @Test("Dismissing a closed panel changes nothing") + func dismissWhenClosedIsNoOp() { + let panel = AttachPanel() + panel.dismiss() + #expect(!panel.isOpen) + #expect(!panel.holdsOverflow) + } + + @Test("Back from a card takes it down and puts the panel back up, in one change") + func barReturnsToMenu() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 874) + + model.returnToMenu() + #expect(!model.attachCard.isOpen) + #expect(model.attachPanel.isOpen) + #expect(model.attachPanel.holdsOverflow) + #expect(model.attachSurfacePhase == .menu) + } + + @Test("Back with no card up leaves the panel down") + func returnToMenuWithoutCardIsNoOp() { + let model = ConversationBarModel() + model.returnToMenu() + #expect(!model.attachPanel.isOpen) + } + + @Test("Back from the photo card discards the pick") + func backResetsPick() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 874) + model.photosPick.showsLibrary = true + + model.returnToMenu() + #expect(model.photosPick.selection.isEmpty) + #expect(!model.photosPick.showsLibrary) + } + + @Test("Adding hands the loader on and starts a fresh one, so its reads are not cancelled") + func addHandsOffPreloader() { + let pick = AttachPhotosPick() + let before = pick.preloader + var received: AnyObject? + pick.handOff { _, preloader in received = preloader } + #expect(received === before) + #expect(pick.preloader !== before) + #expect(pick.selection.isEmpty) + } + + @Test("The bar asks for room above itself everywhere for the panel, and only over the card for a card") + func barOverflow() { + let model = ConversationBarModel() + #expect(model.overflow == .none) + + model.attachPanel.open() + #expect(model.overflow == .everywhere) + #expect(model.overflow.touchTop == nil) + + model.attachPanel.dismiss() + model.attachPanel.exitDidFinish() + model.attachCard.open(.camera, screenHeight: 874) + model.cardTop = 300 + #expect(model.overflow == .band(top: 300)) + + // Leaving, the card takes no touches; once it has gone, the bar stops overflowing. + model.attachCard.close() + #expect(model.overflow.overflows) + #expect(model.overflow.touchTop == .greatestFiniteMagnitude) + model.attachCard.exitDidFinish() + #expect(model.overflow == .none) + } + + @Test("Over the keyboard the bar claims no room, even with the panel up: the overlay takes its touches") + func overKeyboardClaimsNoRoom() { + let model = ConversationBarModel() + model.overKeyboard.activate(items: [.camera, .photos]) + model.attachPanel.open() + #expect(model.overflow == .none) + } +} + +@MainActor +@Suite("Attach surface") +struct AttachSurfaceTests { + + @Test("The surface shows the menu, then the card in its place, then the landing, then nothing") + func phases() { + let model = ConversationBarModel() + #expect(model.attachSurfacePhase == .collapsed) + #expect(!model.attachSurfaceIsMounted) + + model.attachPanel.open() + #expect(model.attachSurfacePhase == .menu) + #expect(model.attachSurfaceIsMounted) + + // One transaction in the app: the panel closes as the card opens, and the card wins. + model.attachPanel.dismiss() + model.attachCard.open(.camera, screenHeight: 874) + #expect(model.attachSurfacePhase == .card(.camera)) + + let chip = UUID() + model.attachCard.beginLanding(on: chip) + #expect(model.attachSurfacePhase == .card(.camera), "The card stays up until the chip is laid out") + model.attachCard.close() + #expect(model.attachSurfacePhase == .landing) + + model.attachPanel.exitDidFinish() + model.attachCard.exitDidFinish() + #expect(model.attachSurfaceIsMounted, "The surface stays until it has landed") + model.attachCard.endLanding() + #expect(model.attachSurfacePhase == .collapsed) + #expect(!model.attachSurfaceIsMounted) + } + + @Test("Only the menu shows rows, and only a card shows card content") + func contentPerPhase() { + #expect(AttachSurfacePhase.menu.showsRows) + #expect(AttachSurfacePhase.menu.shownCard == nil) + #expect(!AttachSurfacePhase.card(.photos).showsRows) + #expect(AttachSurfacePhase.card(.photos).shownCard == .photos) + #expect(!AttachSurfacePhase.collapsed.showsRows && AttachSurfacePhase.collapsed.shownCard == nil) + #expect(!AttachSurfacePhase.landing.showsRows && AttachSurfacePhase.landing.shownCard == nil) + } + + @Test("One shape per phase: +, the menu, the card, and the chip, each with its own radius") + func shapes() { + let plus = CGRect(x: 16, y: 700, width: 44, height: 44) + let menu = CGRect(x: 16, y: 600, width: 240, height: 144) + let card = CGRect(x: 12, y: 300, width: 378, height: 500) + let chip = CGRect(x: 20, y: 640, width: 56, height: 56) + + let collapsed = AttachSurfaceLayout.shape(for: .collapsed, plus: plus, menu: menu, card: card, landing: chip) + #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: AttachSurfaceLayout.plusCornerRadius)) + let open = AttachSurfaceLayout.shape(for: .menu, plus: plus, menu: menu, card: card, landing: nil) + #expect(open == AttachSurfaceShape(rect: menu, cornerRadius: AttachSurfaceLayout.menuCornerRadius)) + let carded = AttachSurfaceLayout.shape(for: .card(.camera), plus: plus, menu: menu, card: card, landing: nil) + #expect(carded == AttachSurfaceShape(rect: card, cornerRadius: AttachSurfaceLayout.cardCornerRadius)) + let landed = AttachSurfaceLayout.shape(for: .landing, plus: plus, menu: menu, card: card, landing: chip) + #expect(landed == AttachSurfaceShape(rect: chip, cornerRadius: AttachSurfaceLayout.chipCornerRadius)) + let waiting = AttachSurfaceLayout.shape(for: .landing, plus: plus, menu: menu, card: card, landing: nil) + #expect(waiting == carded, "Without the chip's frame the surface holds the card's") + } + + @Test("In the bar the menu stands on +; over the keyboard it straddles the composer's bottom edge") + func menuPlacement() { + let plus = CGRect(x: 16, y: 700, width: 44, height: 44) + let size = CGSize(width: 240, height: 144) + let standing = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .standsOnPlus) + #expect(standing == CGRect(x: 16, y: 600, width: 240, height: 144)) + let straddling = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .straddlesPlus) + #expect(straddling == AttachOverlayLayout.panelFrame(plusFrame: plus, size: size)) + } + + @Test("The bar's card stands on the row's bottom edge, out to the keyboard-up margin") + func barCard() { + let rect = AttachSurfaceLayout.barCardRect(row: CGSize(width: 340, height: 44), outset: 12, height: 500) + #expect(rect == CGRect(x: -12, y: -456, width: 364, height: 500)) + } + + @Test("Only the menu takes touches while it shows, and only the card while it is up") + func regions() { + let menu = CGRect(x: 0, y: 0, width: 10, height: 10) + let card = CGRect(x: 0, y: 0, width: 20, height: 20) + #expect(AttachSurfaceLayout.regions(for: .menu, menu: menu, card: card) == [.panel: menu]) + #expect(AttachSurfaceLayout.regions(for: .card(.photos), menu: menu, card: card) == [.card: card]) + #expect(AttachSurfaceLayout.regions(for: .collapsed, menu: menu, card: card).isEmpty) + #expect(AttachSurfaceLayout.regions(for: .landing, menu: menu, card: card).isEmpty) + } + + @Test("A landing waits for the chip's frame, then shrinks the card onto it") + func landingWaitsForChip() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 874) + let chip = UUID() + model.attachCard.beginLanding(on: chip) + + model.landingChipDidLayout(.zero) + #expect(model.attachCard.isOpen, "An unlaid-out chip has no frame to land on") + let frame = CGRect(x: 20, y: 600, width: 56, height: 56) + model.landingChipDidLayout(frame) + #expect(!model.attachCard.isOpen) + } + + @Test("A chip laid out with no landing under way is ignored") + func landingIgnoresOtherChips() { + let card = AttachCard() + #expect(!card.landingChipDidLayout(CGRect(x: 0, y: 0, width: 56, height: 56))) + #expect(card.landingChipFrame == nil) + } +} + +@Suite("Attach motion") +struct AttachMotionTests { + + @Test("Reduce Motion cross-fades instead of morphing") + func reduceMotionCrossFades() { + #expect(AttachMotion(reduceMotion: true) == .crossFade) + #expect(AttachMotion(reduceMotion: false) == .morph) + } + + @Test("Only the morph moves the surface; under Reduce Motion it stands still and its content fades") + func onlyMorphAnimatesGeometry() { + #expect(AttachMotion.morph.animatesGeometry) + #expect(!AttachMotion.crossFade.animatesGeometry) + } + + @Test("The morph grows chips from the chat's tuned scale; the cross-fade holds full size") + func enterScales() { + #expect(AttachMotion.morph.chipEnterScale == ChatMotion.composerChipEnterScale) + #expect(AttachMotion.crossFade.chipEnterScale == 1) + } + + @Test("The attach flow reuses tuned springs rather than numbers of its own") + func tokensReuseTunedSprings() { + #expect(ChatMotion.attachPanel == ChatMotion.swap) + #expect(ChatMotion.composerChip == ChatMotion.reaction) + } + + @Test("The card opens and closes without overshoot") + func attachCardDoesNotOvershoot() { + #expect(ChatMotion.attachCard == ChatMotion.keyboardScroll) + #expect(ChatMotion.attachCard.bounce == 0) + } +} diff --git a/FlipcashTests/Chat/AttachWarmUpTests.swift b/FlipcashTests/Chat/AttachWarmUpTests.swift new file mode 100644 index 000000000..63fda095d --- /dev/null +++ b/FlipcashTests/Chat/AttachWarmUpTests.swift @@ -0,0 +1,81 @@ +// +// AttachWarmUpTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import AVFoundation +@testable import Flipcash + +@MainActor +@Suite("Attach warm-up") +struct AttachWarmUpTests { + + @Test("Without camera access the warm-up makes no camera, so nothing can prompt", arguments: [ + AVAuthorizationStatus.notDetermined, .denied, .restricted, + ]) + func noAccessMakesNoCamera(status: AVAuthorizationStatus) { + let warmUp = AttachWarmUp(cameraAccess: { status }) + warmUp.update(wanted: true) + #expect(warmUp.isWarm) + #expect(!warmUp.hasCamera) + } + + @Test("With access the panel opening makes the camera and starts preparing it") + func accessWarmsCamera() { + let warmUp = AttachWarmUp(cameraAccess: { .authorized }) + warmUp.update(wanted: true) + #expect(warmUp.hasCamera) + } + + @Test("Closing the panel without a choice cools the warm-up") + func closingCools() { + let warmUp = AttachWarmUp(cameraAccess: { .authorized }) + warmUp.update(wanted: true) + warmUp.update(wanted: false) + #expect(!warmUp.isWarm) + } + + @Test("A chat whose menu never opens never makes a camera") + func idleMakesNoCamera() { + let warmUp = AttachWarmUp(cameraAccess: { .authorized }) + warmUp.update(wanted: false) + #expect(!warmUp.hasCamera) + #expect(!warmUp.isWarm) + } + + @Test("Photos is ready once the picker has been mounted for its lead, and not before") + func photosReadyAfterLead() { + let warmUp = AttachWarmUp(cameraAccess: { .denied }) + #expect(!warmUp.isReady(for: .photos)) + + warmUp.pickerDidMount() + let mounted = try! #require(warmUp.pickerMountedAt) + #expect(!warmUp.isReady(for: .photos, at: mounted)) + #expect(warmUp.isReady(for: .photos, at: mounted + AttachWarmUp.pickerLead)) + + warmUp.pickerDidUnmount() + #expect(!warmUp.isReady(for: .photos)) + } + + @Test("A camera without access is ready at once: its card asks, nothing is mounted to wait on") + func cameraWithoutAccessIsReady() { + let warmUp = AttachWarmUp(cameraAccess: { .notDetermined }) + #expect(warmUp.isReady(for: .camera)) + #expect(warmUp.isReady(for: .cash)) + } + + @Test("A card whose content is late opens within the readiness budget anyway") + func whenReadyGivesUpAtBudget() async { + let warmUp = AttachWarmUp(cameraAccess: { .denied }) + let start = ContinuousClock.now + await withCheckedContinuation { continuation in + warmUp.whenReady(for: .photos) { continuation.resume() } + } + let waited = ContinuousClock.now - start + #expect(waited >= AttachWarmUp.readinessBudget) + #expect(waited < .seconds(1)) + } +} diff --git a/FlipcashTests/Chat/ChatCameraSheetTests.swift b/FlipcashTests/Chat/ChatCameraSheetTests.swift index 9e9d66d26..f2ebe2fd5 100644 --- a/FlipcashTests/Chat/ChatCameraSheetTests.swift +++ b/FlipcashTests/Chat/ChatCameraSheetTests.swift @@ -40,6 +40,37 @@ struct ChatCameraSheetTests { #expect(ChatPhotoCamera.flipped(from) == to) } + @Test("The flash toggles between off and on", arguments: [ + (AVCaptureDevice.FlashMode.off, AVCaptureDevice.FlashMode.on), + (.on, .off), + (.auto, .off), + ]) + func flashToggles(from: AVCaptureDevice.FlashMode, to: AVCaptureDevice.FlashMode) { + #expect(ChatPhotoCamera.toggled(from) == to) + } + + @Test("A shot asks for the flash only when the lens can fire it", arguments: [ + (AVCaptureDevice.FlashMode.on, [AVCaptureDevice.FlashMode.off, .on, .auto], AVCaptureDevice.FlashMode.on), + (.on, [.off], .off), + (.on, [], .off), + (.off, [.off, .on], .off), + ]) + func flashResolvesAgainstSupport( + requested: AVCaptureDevice.FlashMode, + supported: [AVCaptureDevice.FlashMode], + expected: AVCaptureDevice.FlashMode + ) { + #expect(ChatPhotoCamera.resolvedFlashMode(requested, supported: supported) == expected) + } + + @Test("A fresh camera starts with the flash off and toggles it on") + func freshCameraFlashOff() { + let camera = ChatPhotoCamera() + #expect(camera.flashMode == .off) + camera.toggleFlash() + #expect(camera.flashMode == .on) + } + @Test("The shutter on a camera that never started returns no photo") func captureBeforeStartReturnsNil() async { let camera = ChatPhotoCamera() diff --git a/FlipcashTests/Chat/ChatPhotoStagingTests.swift b/FlipcashTests/Chat/ChatPhotoStagingTests.swift index 2e5a171ba..5ff29af90 100644 --- a/FlipcashTests/Chat/ChatPhotoStagingTests.swift +++ b/FlipcashTests/Chat/ChatPhotoStagingTests.swift @@ -5,8 +5,10 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import ImageIO import Testing import UIKit +import UniformTypeIdentifiers import FlipcashCore @testable import Flipcash @@ -78,6 +80,154 @@ struct ChatPhotoStagingTests { #expect(staged.map(\.image) == [picked[0]]) #expect(composer.chips.count == ComposerModel.maxAttachments) } + + @Test("Added photos that are already loaded stage at once, in selection order, handing off the first") + func addedLoadedStageAtOnce() async { + let composer = ComposerModel() + let picked = images(3) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { picked[$0] }, load: { picked[$0] } + ) + + #expect(added.remainder == nil) + #expect(composer.chips.map(\.image) == picked) + #expect(added.handOff == composer.chips.first?.id) + } + + @Test("Added photos still loading stage after the loaded ones without blocking, keeping selection order") + func addedUnloadedStageLater() async { + let composer = ComposerModel() + let picked = images(3) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { $0 == 0 ? picked[0] : nil }, load: { picked[$0] } + ) + + #expect(composer.chips.map(\.image) == [picked[0]]) + #expect(added.handOff == composer.chips.first?.id) + await added.remainder?.value + #expect(composer.chips.map(\.image) == picked) + } + + @Test("When the first added photo is still loading nothing is handed off and nothing waits for it") + func addedFirstUnloadedHandsOffNothing() async { + let composer = ComposerModel() + let picked = images(2) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { $0 == 1 ? picked[1] : nil }, load: { picked[$0] } + ) + + #expect(added.handOff == nil) + #expect(composer.chips.isEmpty) + await added.remainder?.value + // The second, though loaded first, still lands after the first. + #expect(composer.chips.map(\.image) == picked) + } + + @Test("Adding stops at a full composer") + func addedStopsWhenFull() throws { + let composer = ComposerModel() + for image in images(ComposerModel.maxAttachments - 1) { + try #require(composer.stageChip(image: image, uploader: uploader) != nil) + } + let picked = images(3) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { picked[$0] }, load: { picked[$0] } + ) + + #expect(added.remainder == nil) + #expect(composer.chips.count == ComposerModel.maxAttachments) + #expect(composer.chips.last?.image == picked[0]) + } + + @Test("A photo with a sideways EXIF orientation decodes with the turn drawn into its pixels") + func decodeBakesOrientation() async throws { + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let pixels = try #require(UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3), format: format).image { context in + UIColor.blue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 3)) + }.cgImage) + CGImageDestinationAddImage(destination, pixels, [kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue] as CFDictionary) + #expect(CGImageDestinationFinalize(destination)) + + let decoded = try #require(await ChatPhotoStaging.decode(data as Data)) + + #expect(decoded.imageOrientation == .up) + #expect(decoded.size == CGSize(width: 3, height: 4)) + } +} + +@MainActor +@Suite("Chat photo preloader") +struct ChatPhotoPreloaderTests { + + private static func image() -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2), format: format).image { _ in } + } + + @Test("Selecting starts a load for each new item and keeps what it loads") + func selectingLoads() async { + let image = Self.image() + let preloader = ChatPhotoPreloader { _ in image } + + preloader.update(selection: [1, 2]) + #expect(preloader.trackedItems == [1, 2]) + + #expect(await preloader.image(for: 1) === image) + #expect(await preloader.image(for: 2) === image) + } + + @Test("An item already loading is not loaded again") + func noDuplicateLoads() async { + var loads: [Int] = [] + let preloader = ChatPhotoPreloader { item in + loads.append(item) + return Self.image() + } + + preloader.update(selection: [1]) + preloader.update(selection: [1, 2]) + _ = await preloader.image(for: 1) + _ = await preloader.image(for: 2) + + #expect(loads.sorted() == [1, 2]) + } + + @Test("Deselecting drops the item's load and image") + func deselectingDrops() async { + let preloader = ChatPhotoPreloader { _ in Self.image() } + preloader.update(selection: [1, 2]) + _ = await preloader.image(for: 1) + + preloader.update(selection: [2]) + + #expect(preloader.trackedItems == [2]) + #expect(preloader.loadedImage(for: 1) == nil) + } + + @Test("Reset cancels every load and lets go of every image") + func resetClears() async { + let preloader = ChatPhotoPreloader { _ in Self.image() } + preloader.update(selection: [1, 2]) + _ = await preloader.image(for: 1) + + preloader.reset() + + #expect(preloader.trackedItems.isEmpty) + #expect(preloader.images.isEmpty) + } } @MainActor @@ -178,37 +328,123 @@ struct ComposerOutgoingTests { } @MainActor -@Suite("Chat camera slot") -struct ChatCameraSlotTests { +@Suite("Attach card") +struct AttachCardTests { + + @Test("A fresh card is down, at the fallback height, and holds no room above the bar") + func freshCardIsClosed() { + let card = AttachCard() + #expect(!card.isOpen) + #expect(!card.holdsOverflow) + #expect(card.height == AttachCard.fallbackHeight) + } + + @Test("The card takes a fixed share of the screen's height", arguments: [ + (CGFloat(874), CGFloat(507)), + (CGFloat(667), CGFloat(387)), + ]) + func takesShareOfScreen(screenHeight: CGFloat, expected: CGFloat) { + let card = AttachCard() + card.open(.camera, screenHeight: screenHeight) + #expect(card.height == expected) + } + + @Test("An unmeasured screen falls back to the fixed height") + func unmeasuredScreenFallsBack() { + #expect(AttachCard.height(forScreenHeight: 0) == AttachCard.fallbackHeight) + } + + @Test("A keyboard rising takes down the card that is up, and its room above the bar", arguments: [ + AttachCard.Content.camera, + .photos, + ]) + func keyboardClosesCard(content: AttachCard.Content) { + let card = AttachCard() + card.open(content, screenHeight: 874) + #expect(card.content == content) + #expect(card.holdsOverflow) + + card.keyboardWillShow() + #expect(card.content == nil) + #expect(!card.holdsOverflow) + } + + @Test("A keyboard rising as a card starts landing on its chip leaves the card up to land") + func keyboardLeavesLandingAlone() { + let card = AttachCard() + let chipID = UUID() + card.open(.photos, screenHeight: 874) + card.beginLanding(on: chipID) - @Test("A fresh slot is closed at the fallback height") - func freshSlotIsClosed() { - let slot = ChatCameraSlot() - #expect(!slot.isOpen) - #expect(slot.height == ChatCameraSlot.fallbackHeight) + card.keyboardWillShow() + #expect(card.content == .photos) + #expect(card.landingChipID == chipID) } - @Test("The slot takes the keyboard's height above the bottom safe area") - func takesKeyboardHeight() { - let slot = ChatCameraSlot() - slot.keyboardWillShow(height: 336, safeAreaBottom: 34) - #expect(slot.height == 302) + @Test("Over the keyboard, an input view swap announcing the keyboard leaves an opening card alone") + func keyboardGateHoldsOverKeyboard() { + let card = AttachCard() + card.closesOnKeyboardShow = false + card.open(.camera, screenHeight: 874) + card.keyboardWillShow() + #expect(card.content == .camera) } - @Test("A keyboard rising closes the camera") - func keyboardClosesCamera() { - let slot = ChatCameraSlot() - slot.open() - #expect(slot.isOpen) + @Test("The card holds the camera or the photo card, not both") + func holdsOneContent() { + let card = AttachCard() + card.open(.photos, screenHeight: 874) + card.open(.camera, screenHeight: 874) + #expect(card.content == .photos) - slot.keyboardWillShow(height: 336, safeAreaBottom: 34) - #expect(!slot.isOpen) + card.close() + card.open(.camera, screenHeight: 874) + #expect(card.content == .camera) } - @Test("A keyboard no taller than the safe area leaves the height alone") - func ignoresDegenerateKeyboard() { - let slot = ChatCameraSlot() - slot.keyboardWillShow(height: 20, safeAreaBottom: 34) - #expect(slot.height == ChatCameraSlot.fallbackHeight) + @Test("A card back up before the last exit finished keeps its room above the bar") + func reopenKeepsOverflow() { + let card = AttachCard() + card.open(.camera, screenHeight: 874) + card.close() + card.open(.photos, screenHeight: 874) + card.exitDidFinish() + #expect(card.holdsOverflow) } } + +@MainActor +@Suite("Chat camera capture hand-off") +struct ChatCameraCaptureHandOffTests { + + @Test("A capture waits for its chip's frame, then the card shrinks onto it and the chip shows") + func captureLandsOnChip() { + let card = AttachCard() + let chipID = UUID() + card.open(.camera, screenHeight: 874) + + card.beginLanding(on: chipID) + #expect(card.isOpen, "The card holds until the chip is laid out") + #expect(card.landingChipID == chipID) + + let frame = CGRect(x: 20, y: 700, width: 64, height: 64) + #expect(card.landingChipDidLayout(frame)) + #expect(card.landingChipFrame == frame) + + card.close() + card.endLanding() + card.exitDidFinish() + #expect(card.landingChipID == nil) + #expect(!card.holdsOverflow) + } + + @Test("A chip laid out after the card has gone has nothing to land") + func lateChipLandsNothing() { + let card = AttachCard() + card.open(.camera, screenHeight: 874) + card.beginLanding(on: UUID()) + card.close() + #expect(!card.landingChipDidLayout(CGRect(x: 0, y: 0, width: 64, height: 64))) + } + +} diff --git a/FlipcashTests/Chat/ChatPhotosCardTests.swift b/FlipcashTests/Chat/ChatPhotosCardTests.swift new file mode 100644 index 000000000..1d0952b6d --- /dev/null +++ b/FlipcashTests/Chat/ChatPhotosCardTests.swift @@ -0,0 +1,29 @@ +// +// ChatPhotosCardTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import Testing +@testable import Flipcash + +@MainActor +@Suite("Chat photos card") +struct ChatPhotosCardTests { + + // The picker runs out of process, so a tap can't be driven from here; this pins the behaviour + // that makes one reach the selection. With `.ordered`, a tapped photo was marked in the picker + // but the binding never changed, so the Add pill never appeared. + @Test("The picker reports each tap to the selection as it happens, in order") + func selectionBehavior_isContinuousAndOrdered() { + #expect(ChatPhotosCard.selectionBehavior == .continuousAndOrdered) + } + + @Test("The Add pill counts the selection") + func addTitle_countsSelection() { + #expect(ChatPhotosCard.addTitle(count: 1) == "Add 1 photo") + #expect(ChatPhotosCard.addTitle(count: 3) == "Add 3 photos") + } +} diff --git a/FlipcashTests/Chat/ComposerChipLayoutTests.swift b/FlipcashTests/Chat/ComposerChipLayoutTests.swift new file mode 100644 index 000000000..74b7be06b --- /dev/null +++ b/FlipcashTests/Chat/ComposerChipLayoutTests.swift @@ -0,0 +1,79 @@ +// +// ComposerChipLayoutTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +@testable import Flipcash + +@MainActor +@Suite("Composer chip layout") +struct ComposerChipLayoutTests { + + @Test("A portrait photo's chip stays inside its square, clear of the row below") + func portraitChip_staysInsideItsSquare() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let portrait = UIGraphicsImageRenderer(size: CGSize(width: 200, height: 400)).image { ctx in + UIColor.green.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 200, height: 400)) + } + host.composer.stageChip(image: portrait, preview: portrait, uploader: ChatMediaUploader(blob: MockChatMediaBlobStore())) + await host.settle() + + // Down the chip's leading quarter, clear of the centred badge and the trailing remove button. + // The strip sits inside the field, which starts past `+`. Before the fix the photo filled at + // its own aspect and spilled past the 56pt square, over the text row below it. + let chipLeading = try #require( + stride(from: CGFloat(0), to: host.window.bounds.width, by: 2).first { x in + host.renderedColumn(atX: x, backdrop: .black).contains { $0.isGreenDominant } + }, + "The chip was not drawn" + ) + let column = host.renderedColumn(atX: chipLeading + 12, backdrop: .black) + let greenRows = column.filter { $0.isGreenDominant }.count + #expect(greenRows > 0, "The chip was not drawn") + #expect(greenRows <= Int(ComposerChipStrip.chipSize) + 1, "The chip is \(greenRows)pt tall") + } +} + +private extension AttachPanelHost { + + /// The window's rendered colours down the column at `x`, one per point. + func renderedColumn(atX x: CGFloat, backdrop: UIColor) -> [UIColor] { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { ctx in + backdrop.setFill() + ctx.fill(window.bounds) + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let cg = image.cgImage! + let height = cg.height + var pixels = [UInt8](repeating: 0, count: height * 4) + let context = CGContext( + data: &pixels, width: 1, height: height, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.draw(cg, in: CGRect(x: -x, y: 0, width: CGFloat(cg.width), height: CGFloat(height))) + return (0.. 0.35 && g > r * 2 && g > b * 2 + } +} diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index e25d6e0d5..5f061a5d1 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -1630,6 +1630,43 @@ struct ConversationControllerTests { #expect(preview == "You received $1.00 of Jeffy") } + /// The Chats row for an end-to-end-encrypted chat whose newest message is `content`. + private func encryptedConversation( + _ content: ConversationMessage.Content, + from sender: UserID?, + failure: ConversationMessage.DecryptFailure? = nil + ) -> Conversation { + let sealed = ConversationMessage.Sealed(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + return Conversation( + id: ConversationID.test(1), + members: [], + lastMessage: ConversationMessage( + id: MessageID(value: 1), + senderID: sender, + content: content, + date: Date(timeIntervalSince1970: 1), + unreadSeq: 1, + sealed: sealed, + decryptFailure: failure + ), + lastActivity: Date(timeIntervalSince1970: 1), + useE2Ee: true + ) + } + + @Test("an encrypted chat previews its decrypted text, and nothing while it cannot be read") + func encryptedPreview() { + let me = UUID() + let them = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + let ciphertext = ConversationMessage.Content.encrypted(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + + #expect(controller.lastMessagePreview(for: encryptedConversation(.text("gm"), from: them)) { _ in nil } == "gm") + #expect(controller.lastMessagePreview(for: encryptedConversation(.text("gm"), from: me)) { _ in nil } == "You: gm") + #expect(controller.lastMessagePreview(for: encryptedConversation(ciphertext, from: them, failure: .authentication)) { _ in nil } == nil) + #expect(controller.lastMessagePreview(for: encryptedConversation(ciphertext, from: them)) { _ in nil } == nil) + } + @Test("the viewer's own message is prefixed with You, in a DM as in a group") func textPreviewPrefixesSelf() { let me = UUID() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index 37c45e657..b84749603 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -160,6 +160,25 @@ public nonisolated enum ChatMotion { public static let reactionReflow = reflow /// A pill's count or selected state changing in place. public static let reactionChange = ChatSpring(duration: 0.24, bounce: 0) + /// The attach panel growing out of `+` and collapsing back. The bar's own spring, since it moves + /// a piece of the bar; no reference frames were measured for it. A morph that also opens or + /// closes the camera or photo card rides `attachCard` instead. + public static let attachPanel = swap + /// The camera or photo card growing out of the panel, and closing into a chip or back into the + /// panel. Zero bounce, from `keyboardScroll`: an overshoot on a card half the screen tall carries + /// its edge well past where it rests. + public static let attachCard = keyboardScroll + /// The leaving side's content fading out while the panel and a card morph into each other: done in + /// the first third of `attachCard`, so only the shared surface is seen changing shape. + public static let attachContentOut = Animation.easeOut(duration: attachCard.duration * 0.35) + /// The arriving side's content fading in once the shared surface has nearly landed, so the two + /// directions of the morph are one motion played either way. Eased out: an ease-in ends at full + /// speed, and the content popped the last of the way in. + public static let attachContentIn = Animation.easeOut(duration: attachCard.duration * 0.55) + .delay(attachCard.duration * 0.45) + /// A chip arriving in or leaving the composer's strip, and its neighbours sliding to make room. + /// Small and tapped-for like a reaction pill, so it travels on the pill's spring. + public static let composerChip = reaction // MARK: - Scales @@ -181,6 +200,12 @@ public nonisolated enum ChatMotion { /// How far below its slot a row appended at the bottom starts, as a share of the room its arrival /// makes. At 1 it rides up in step with the rows it pushes, so it never overlaps the one above. public static let insertionRise: CGFloat = 0.35 + /// The attach panel's starting scale as it grows out of `+`, and its ending scale as it + /// collapses back. The pill's figure: small enough that the panel reads as coming out of the + /// button rather than fading in beside it. + public static let attachPanelEnterScale: CGFloat = reactionEnterScale + /// A composer chip's starting scale as it arrives, and its ending scale as it is removed. + public static let composerChipEnterScale: CGFloat = reactionEnterScale // MARK: - Timing diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 16c4ec1ec..6e44d29f5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -35,7 +35,7 @@ public final class ChatScreenViewController: UIViewController { /// capture, that put the composer row 24pt — half the strip's 48 — from where it belongs. Pinned /// to the bottom of a box that clips it, the bar's frame can jump to the strip's full height /// while the box uncovers it, and the composer row does not move at all. - private let barClip = UIView() + private let barClip = BarClipView() /// Carries the chat background on below the fade, so the keyboard has that behind it rather /// than the transcript. /// @@ -59,6 +59,26 @@ public final class ChatScreenViewController: UIViewController { /// ramp's dark end never lands on the bubble the transcript just moved down. private var fadeTopConstraint: NSLayoutConstraint! private var barClipHeightConstraint: NSLayoutConstraint! + + /// Whether the bar draws and takes touches above its own frame, for a panel it floats over the + /// transcript. While set, the bar's view reaches the top of the screen with its content still on + /// its bottom edge, the clip stops cutting it, and the transcript's inset is left alone. + public var barOverflowsTop = false { + didSet { + guard barOverflowsTop != oldValue, barClipHeightConstraint != nil else { return } + // The bar is already as tall as the screen and pinned to the clip's bottom, so letting + // it draw and take touches above the clip is all the overflow needs. + barClip.clipsToBounds = !barOverflowsTop + barClip.overflowTarget = barOverflowsTop ? bar : nil + } + } + /// Where, in window coordinates, the bar starts taking touches while it overflows, or `nil` for + /// everywhere it reaches. A card standing over the transcript takes touches on itself and lets the + /// rest through; a panel that dismisses on any outside touch takes them all. + public var barOverflowTouchTop: CGFloat? { + didSet { barClip.overflowTouchTop = barOverflowTouchTop } + } + /// Keeps the bar above the keyboard. Not `view.keyboardLayoutGuide`: see `KeyboardFloor`. private var keyboardFloor: KeyboardFloor! @@ -310,6 +330,8 @@ public final class ChatScreenViewController: UIViewController { /// The lift on screen, if any: its overlay and the message it raised. private var lift: (overlay: MessageLiftOverlay, message: ChatMessage)? + /// The layer over the transcript that takes its touches, if one is up. + private var transcriptShield: TranscriptShield? public override func viewDidLoad() { super.viewDidLoad() @@ -661,6 +683,53 @@ public final class ChatScreenViewController: UIViewController { /// the mention list, sent whenever it changes. public var onMentionRoomChange: ((CGFloat) -> Void)? + /// How far the keyboard reaches up into the screen, in points; zero while it is down. + public var keyboardOverlap: CGFloat { + keyboardFloor?.overlap ?? 0 + } + + /// Whether the composer's field holds the keyboard and can take a replacement input view. + public var canReplaceComposerInputView: Bool { + guard let responder = bar.firstTextInputResponder, responder.isFirstResponder else { return false } + return responder is UITextView || responder is UITextField + } + + /// 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 + public func setComposerInputView(_ inputView: UIView?) -> Bool { + switch bar.firstTextInputResponder { + case let textView as UITextView: + guard textView.inputView !== inputView else { return true } + textView.inputView = inputView + textView.reloadInputViews() + return true + case let textField as UITextField: + guard textField.inputView !== inputView else { return true } + textField.inputView = inputView + textField.reloadInputViews() + return true + default: + return false + } + } + + /// Lays a clear layer over the transcript that takes every touch and fires `onTap` instead, + /// while `isActive` holds; `nil` takes it down. Nothing under it sees the touch, so the keyboard + /// stays up and no message reacts. + public func setTranscriptShield(isActive: @escaping () -> Bool, onTap: (() -> Void)?) { + transcriptShield?.removeFromSuperview() + transcriptShield = nil + guard let onTap else { return } + let shield = TranscriptShield() + shield.isActive = isActive + shield.onTap = onTap + shield.frame = transcript.view.frame + shield.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.insertSubview(shield, aboveSubview: transcript.view) + transcriptShield = shield + } + /// Set the bar's height to its measured SwiftUI content height. `accessories` says which cards /// stand above the composer row, which decides what the clip around the bar does with that height. /// @@ -849,7 +918,7 @@ private final class KeyboardFloor { private var observer: (any NSObjectProtocol)? /// The keyboard's current overlap of the view, in points; zero when it is down. - private var overlap: CGFloat = 0 + private(set) var overlap: CGFloat = 0 init(view: UIView, bottomConstraint: NSLayoutConstraint, keyboardEdgeConstraint: NSLayoutConstraint) { self.view = view @@ -960,3 +1029,40 @@ private extension UIView { } #endif + +/// The box the bar is seen through, which also lets touches reach the bar where it reaches above the +/// box — see ``ChatScreenViewController/barOverflowsTop``. +private final class BarClipView: UIView { + + /// The subview allowed to take touches outside the box's bounds, while the bar overflows. + weak var overflowTarget: UIView? + /// Where, in window coordinates, the target starts taking those touches; `nil` for all of it. + var overflowTouchTop: CGFloat? + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if let overflowTarget { + var region = overflowTarget.frame + if let overflowTouchTop { + let top = max(region.minY, convert(CGPoint(x: 0, y: overflowTouchTop), from: nil).y) + region = CGRect(x: region.minX, y: top, width: region.width, height: max(0, region.maxY - top)) + } + if region.contains(point) { return true } + } + return super.point(inside: point, with: event) + } +} + +/// A clear layer that swallows touches while its predicate holds and passes them through otherwise. +private final class TranscriptShield: UIView { + + var isActive: () -> Bool = { false } + var onTap: () -> Void = {} + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + isActive() && bounds.contains(point) ? self : nil + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + onTap() + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 494dc60fc..8ccb29c49 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -34,6 +34,18 @@ extension View { } } + /// The app's glass surface for a panel that holds its own buttons: Liquid Glass on iOS 26, an + /// ultra-thin material below. Non-interactive, because `.interactive()` on the container competes + /// with its buttons for the tap. + @ViewBuilder + public func panelGlassBackground(cornerRadius: CGFloat) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) + } else { + background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius)) + } + } + /// The app's glass surface clipped to a capsule, for a control whose shape is /// fully rounded — the floating tab bar and the Scan tab's gallery button. @ViewBuilder