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/.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/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 new file mode 100644 index 000000000..1c676bf0c --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -0,0 +1,210 @@ +// +// AttachMenu.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +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, 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] + 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 { + Button { + if !panel.isOpen { + onOpen(items) + } + panel.animate { $0.toggle() } + } 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)) + } + .buttonStyle(.plain) + .glassBackground(cornerRadius: BarMetrics.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .accessibilityLabel("Attach") + .accessibilityValue(panel.isOpen ? "Expanded" : "Collapsed") + .accessibilityIdentifier("attach-menu-button") + .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 new file mode 100644 index 000000000..95a4e1124 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -0,0 +1,294 @@ +// +// 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 + } + } +} + +/// 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 { + + /// 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 isUnavailable = false + /// Whether the flash and flip buttons are unfolded above "…". + @State private var showsMoreControls = false + + @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 phase { + 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) + .onGeometryChange(for: CGSize.self, of: { $0.size }) { viewfinderSize = $0 } + .background(Color.backgroundMain) + // On every phase, so the way back is there while access is asked for or denied too. + .overlay(alignment: .bottom) { + controls(isLive: isLive) + } + } + + private var viewfinder: some View { + ChatCameraPreview(session: camera.session) + // 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 await camera.prepare() + } catch { + isUnavailable = true + } + } + } + + /// 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 { + backButton + Spacer() + if isLive { + shutter + } + Spacer() + 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) + } + } + .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) { + ChatCameraControlLabel(systemImage: "chevron.left") + } + .buttonStyle(.plain) + .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. +/// +/// 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/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..2b48a3ce6 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift @@ -0,0 +1,292 @@ +// +// 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, 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. +@Observable +final class ChatPhotoCamera { + + /// Why the camera could not be set up. + 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. + 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 + + /// 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() + @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] = [:] + /// 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 {} + + /// 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 + } + } + + /// 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 - + + /// 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() } + + session.sessionPreset = .photo + guard session.canAddOutput(output) else { + throw SetupError.configurationRejected + } + session.addOutput(output) + 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. + private func start() { + let session = session + 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, or keeps one still being prepared from starting. + func stop() { + wantsRunning = false + isRunning = false + 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 { + adopt(try Self.attach(position, to: session, output: output), at: position) + } + + // 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() + // 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) { + 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 + + 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 +// `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/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 new file mode 100644 index 000000000..afe0f5979 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -0,0 +1,109 @@ +// +// 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 + } + + /// 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 + 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 + } + + /// 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 + 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 aed97cd68..754140835 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,21 @@ 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 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 } + /// 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 +159,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 +211,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 @@ -269,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, @@ -285,7 +319,35 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onGateAddFunds: onGateAddFunds, onGateJoin: onGateJoin, isJoiningChat: isJoiningChat, - mentions: mentions + mentions: mentions, + acceptsMedia: acceptsMedia, + 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) } + }, + onCameraCancel: { [model = barModel] in + // Back into the panel, with the keyboard left down. + model.returnToMenu() + }, + 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() + }, + // 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( @@ -298,15 +360,87 @@ 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 } + /// 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..763083366 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -0,0 +1,94 @@ +// +// 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 + /// 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. + 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, preview: UIImage? = nil) { + self.image = image + self.preview = preview + } + + /// 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..af495806c --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -0,0 +1,193 @@ +// +// 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 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. +struct ComposerChipStrip: View { + + /// Side of a chip's square thumbnail, per the spec's 56pt chips. + 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. + static func isShown(chipCount: Int, isEditing: Bool) -> Bool { + 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") + } +} + +/// 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 + // 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() + } + } + .overlay { badge } + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) + .frame(width: size, height: size) + .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..a9494076a 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,27 @@ final class ComposerModel { mode = .new } - /// Empties the field after a successful send. + /// 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, preview: UIImage? = nil, uploader: ChatMediaUploader) -> ComposerChip? { + guard chips.count < Self.maxAttachments else { return nil } + let chip = ComposerChip(image: image, preview: preview) + 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 +242,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..ef3b64b3a 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,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 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 @@ -47,7 +171,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 +241,26 @@ 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 + /// 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: (ChatCameraCapture) -> Void = { _ in } + /// Fired by the camera card's back chevron. + var onCameraCancel: () -> Void = {} + /// 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 } /// 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) @@ -100,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. @@ -130,34 +305,64 @@ 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 motion = AttachMotion(reduceMotion: reduceMotion) + let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { + 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, + // 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 + ) + .hiddenUnderAttachCard(showsCard) + case .attach(let items): + AttachMenu( + items: items, + 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) + } } - if chatExists { - ConversationComposer(conversationID: conversationID, model: model, composer: composer) - .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 @@ -171,6 +376,46 @@ struct ConversationBottomBar: View { .padding(.bottom, BarMetrics.contentPadding) .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) + // The strip arriving with its first chip and leaving with its last, on the chip spring unless + // a capture's animation is already carrying it. + .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 @@ -203,7 +448,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 +503,73 @@ struct ConversationBottomBar: View { return listedCandidates } + /// 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, + 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. @@ -632,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) @@ -677,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 @@ -719,33 +1056,41 @@ 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 // 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 +1145,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..9d3ee13c3 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,71 @@ struct ConversationScreen: View { onGateJoin: joinChat, isJoiningChat: isJoiningChat, mentions: mentions, - authorAvatars: authorAvatars + authorAvatars: authorAvatars, + acceptsMedia: acceptsMedia, + onCameraCapture: stageCapturedPhoto, + onPhotosAdded: stageAddedPhotos, + mintMediaURL: mintMediaURL, + onMediaTap: openMediaViewer ) } + /// 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 !E2eePolicy.shouldEncrypt(conversation) + } + + /// Uploads chat photos on behalf of the signed-in owner. + private var mediaUploader: ChatMediaUploader { + ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) + } + + /// 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. + /// 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 +1160,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/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/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/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 new file mode 100644 index 000000000..f2ebe2fd5 --- /dev/null +++ b/FlipcashTests/Chat/ChatCameraSheetTests.swift @@ -0,0 +1,79 @@ +// +// 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 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() + #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..5ff29af90 --- /dev/null +++ b/FlipcashTests/Chat/ChatPhotoStagingTests.swift @@ -0,0 +1,450 @@ +// +// ChatPhotoStagingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import ImageIO +import Testing +import UIKit +import UniformTypeIdentifiers +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: 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 +@Suite("Composer outgoing send") +struct ComposerOutgoingTests { + + private let uploader = ChatMediaUploader(blob: MockChatMediaBlobStore()) + + private func image() -> 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("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) + + card.keyboardWillShow() + #expect(card.content == .photos) + #expect(card.landingChipID == chipID) + } + + @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("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) + + card.close() + card.open(.camera, screenHeight: 874) + #expect(card.content == .camera) + } + + @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/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/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/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/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/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/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/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..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! @@ -185,6 +205,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 +218,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 } @@ -292,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() @@ -643,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. /// @@ -831,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 @@ -942,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/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/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 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) } }