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/.claude/plans/2026-10-03-pending-photo-persistence.md b/.claude/plans/2026-10-03-pending-photo-persistence.md new file mode 100644 index 000000000..21af559fb --- /dev/null +++ b/.claude/plans/2026-10-03-pending-photo-persistence.md @@ -0,0 +1,70 @@ +# Persist pending chat photo sends + +## Problem + +A pending photo send lives only in memory. That's `ConversationController.pendingMediaChips` (`ComposerChip`, holding a `UIImage`) plus the in-memory `ConversationStore.pendingByConversation` row. If iOS kills the app in the background, the photo and its bubble are both lost. + +On-device evidence, 2026-10-03 15:39–15:45: +- blob `51993f1f…` was reserved, +- the app was backgrounded 3 s later, +- the next log line is a cold launch at 15:45:07, +- the chat showed no photo and no failed bubble. + +## What Android does (text only — Android has no media send) + +- Pending rows are stored in the same Room table as messages, with status `SENDING`/`SENT`/`FAILED`. +- At login, every `SENDING` row is marked `FAILED`, and the user taps retry. +- A retry reuses the same client id, and the server dedupes on `client_message_id`. + +## Design + +### Storage +- Use a JSON manifest plus one JPEG file per photo, following `ChatDraftStore`'s precedent and reasoning. +- `FlipcashStore` (SQLite) is the wrong home because its versioning is destructive, and an outbox can't be re-fetched. +- Location: Application Support, scoped to the owner: + - manifest `flipcash--pending-media.json`, + - photos in `flipcash--pending-media/.jpg`. +- Leave the files at the default protection class, `completeUntilFirstUserAuthentication`. + +### Entry +Each entry holds: +- `clientMessageID` +- `conversationID` +- `createdAt` +- the image file name +- `caption?` and `replyTo?`, exactly as `ChatMediaSendPlan` assigned them to this message +- `stored: UploadedPhoto?` + +`stored` is set the moment `store` returns. `SealedPhoto` is metadata only (blob id, MIME type, size, dimensions, blurhash), so it's safe to persist, and it needs `Codable`. + +### Encode once +- Encode the JPEG before the upload starts and write it to disk then. A kill mid-upload must not lose the photo. +- The upload then sends those same bytes. The retry path never re-encodes a re-decoded image. + +### Lifecycle +- Write the entry when `sendMedia` runs. Attached-but-unsent composer chips are not persisted. +- Update `stored` when the bytes land. +- Delete the entry and its file at the same point `dropPending` runs: after `upsertConversationMessages` succeeds, never before. +- Also delete when the user discards a failed row, and when the failure can't be retried (`.rejected`). + +### Launch +In `ConversationController.start()`, after hydrate: +- Load the manifest. +- Sweep orphans: files without an entry, and entries without a file. +- Re-insert each entry as a pending row, anchored by its `createdAt` rather than `newestMessageID`, and rebuild its chip from the file. +- `stored == nil`: show the row as failed with retry, as Android does. Retry uploads the file. +- `stored != nil`: resume the READY poll automatically, then send. + +### Dedupe +- The risk is a kill after `sendMediaMessage` succeeds but before the entry is deleted. +- Before re-inserting an entry that has `stored`, look for a self-authored media message with that blob id in the loaded messages. If one exists, the photo already sent, so drop the entry. +- An entry with no blob id can't exist on the server, so it's always safe to re-insert. + +## Tests +- The store round-trips its entries and sweeps orphans. +- Reconciliation drops an entry whose blob id is in the loaded messages and keeps one whose blob id isn't. +- At launch, `stored == nil` comes back as failed and `stored != nil` resumes. + +## Known gaps +- An end-to-end encrypted photo sits as a plaintext JPEG in the app's sandbox until it sends. +- `pendingMatch`'s date window: a resumed send's row keeps its old `createdAt`, so the stream echo may not match it. The send RPC's own confirm still removes the row. diff --git a/.claude/plans/2026-10-04-two-row-composer.md b/.claude/plans/2026-10-04-two-row-composer.md new file mode 100644 index 000000000..0163c95c1 --- /dev/null +++ b/.claude/plans/2026-10-04-two-row-composer.md @@ -0,0 +1,20 @@ +# Two-row composer (E2) + +The composer field becomes two rows inside one glass field. + +- Top row: chip strip, then the message text, full width. +- Bottom row: 34pt `+` circle, grey "Send $" capsule, Spacer, white circular send button. +- Field padding 8 all round; the text row is inset 6 more on the leading side. + +## Decisions + +- `ConversationBarLeadingControl` keeps only what stands outside the field: `.cancelEdit`, `.sendCash` (pre-chat CTA), `.none`. +- New `ConversationBarBottomRow` (plus items, pill flag). Empty while editing and before the chat exists. Cash never appears in its menu: the pill replaces it. +- No bottom row (no `+`, no pill, or editing): single-row field with send on the text row. +- `AttachMenu` is restyled to the 34pt circle and rendered by `ConversationComposer`; panel toggle, frame reporting, stand-in and accessibility are unchanged. +- The attach surface stays an overlay on the bar's row; its collapsed radius is `plus.height / 2`. +- `BarMetrics.contentHeight` still means the single-row field. `BarMetrics.twoRowContentHeight` (84) is the two-row field. + +## Tests + +Bottom-row decision, Cash dropped from the menu, collapsed radius, bar height, and `AttachPanelPlacementTests` retargeted to `+`'s new column. 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 cd73bdbd3..afc29cc6e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -10,6 +10,7 @@ import os import SwiftUI import FlipcashCore import FlipcashStore +import FlipcashUI nonisolated private let logger = Logger(label: "flipcash.conversation-controller") @@ -312,6 +313,21 @@ final class ConversationController { /// Keeps the words of a send that failed — see ``FailedSendDrafts``. @ObservationIgnored private var failedSends: FailedSendDrafts? + /// The session's pending photo sends, wired by `SessionContainer` like ``chatDrafts``; a controller + /// without one keeps photo sends in memory only. + @ObservationIgnored var pendingMedia: PendingMediaStore? + + /// Builds the uploader a photo send restored from disk finishes through, wired with + /// ``pendingMedia``. + @ObservationIgnored var restoredPhotoUploader: ((ConversationID) -> ChatMediaUploader)? + + /// 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 local image of each photo this session sent, newest last and capped, so its bubble keeps + /// drawing it after confirm instead of falling back to the BlurHash while the server copy loads. + @ObservationIgnored private var sentMediaImages: [(clientMessageID: UUID, image: UIImage)] = [] + private static let sentMediaImageLimit = 20 /// 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 @@ -327,6 +343,8 @@ final class ConversationController { /// Moves new senders to the front of the mention picker's held suggestions. Set by the session /// after init. @ObservationIgnored var mentionSearch: ServerMentionSearch? + /// Keeps a photo send running for the time iOS allows after the app leaves the screen. + private let backgroundTasks: any BackgroundTaskAsserting init( fetching: any ConversationFetching, @@ -344,9 +362,11 @@ final class ConversationController { typingStoppedLinger: Duration = ConversationTyping.defaultStoppedLinger, typingExpiryClock: TypingExpiryClock = .continuous, reconcileTiming: FeedReconcileTiming = .launch, - receipts: ConversationReceiptReporter? = nil + receipts: ConversationReceiptReporter? = nil, + backgroundTasks: any BackgroundTaskAsserting = UIApplicationBackgroundTasks(name: "chat.photo.send") ) { self.reconcileTiming = reconcileTiming + self.backgroundTasks = backgroundTasks self.fetching = fetching self.membership = membership self.viewerSettings = viewerSettings @@ -450,6 +470,7 @@ final class ConversationController { } startTask = Task { await hydrateFromDatabase(loading: cache, unlessApplied: true) + restorePendingMedia() await openStream() await loadFeed() } @@ -1527,6 +1548,12 @@ final class ConversationController { // the chat and the viewer may have got none of it, so the amount stands on its own. return conversation.type == .group ? label : "You received \(label)" + case .media(_, let caption): + let body = ChatMediaStrings.listPreview(caption: caption) + if isFromSelf { return "You: \(body)" } + guard let senderName else { return body } + return "\(senderName): \(body)" + case .deleted: return nil @@ -1948,16 +1975,16 @@ 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 .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: [ @@ -1965,8 +1992,8 @@ final class ConversationController { "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. } } @@ -2017,6 +2044,322 @@ 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. + /// The seal a photo staged for `conversationID` is encrypted with, nil when the chat takes + /// plaintext photos; throws when the chat encrypts but its seal can't be built. + func photoSeal(for conversationID: ConversationID) async throws -> ChatSeal? { + try await messaging.photoSeal(owner: owner, conversationID: conversationID) + } + + /// Decrypts `conversationID`'s end-to-end encrypted photo blobs, or nil while the chat's key is + /// unknown or the chat doesn't encrypt. + func mediaBlobDecrypt(for conversationID: ConversationID) async -> (@Sendable (Data, BlobID, SealedBlob) throws -> Data)? { + guard let seal = await messaging.openingSeal(owner: owner, conversationID: conversationID) else { return nil } + return { blob, blobID, sealed in + do { + return try seal.decryptBlob(blob, blobID: blobID, sealed: sealed) + } catch { + logger.warning("Encrypted chat photo failed to open", metadata: [ + "conversationID": "\(conversationID)", + "blobID": "\(blobID)", + "error": "\(error)", + ]) + throw error + } + } + } + + @discardableResult + func sendMedia( + _ chips: [ComposerChip], + caption: String?, + to conversationID: ConversationID, + repliedTo: MessageID? = nil + ) async -> Bool { + let assertion = BackgroundTimeAssertion(assertions: backgroundTasks) + assertion.begin() + defer { assertion.end() } + + 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 photo bubble whose transcript id is `messageID` — pending, or sent + /// from this device this session — or `nil` when the row is not one of those. + func pendingMediaImage(forMessageID messageID: String) -> UIImage? { + guard let id = UUID(uuidString: messageID) else { return nil } + return pendingMediaChips[id]?.image ?? sentMediaImages.last { $0.clientMessageID == id }?.image + } + + private func keepSentImage(_ image: UIImage, clientMessageID: UUID) { + sentMediaImages.append((clientMessageID, image)) + if sentMediaImages.count > Self.sentMediaImageLimit { + sentMediaImages.removeFirst(sentMediaImages.count - Self.sentMediaImageLimit) + } + } + + /// The send progress of 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 pendingMediaProgress(forMessageID messageID: String) -> ChatPhotoSendProgress? { + UUID(uuidString: messageID).flatMap { pendingMediaChips[$0]?.progress } + } + + private func insertPendingMedia(_ message: ChatMediaSendPlan.MediaMessage, into conversationID: ConversationID) -> UUID { + let clientMessageID = UUID() + let chip = message.chip + let createdAt = Date.now + let pending = pendingMediaRow( + chip: chip, caption: message.caption, repliedTo: message.replyTo, + clientMessageID: clientMessageID, date: createdAt, status: .sending + ) + 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 + keepOnDisk(chip, clientMessageID: clientMessageID, conversationID: conversationID, createdAt: createdAt, caption: message.caption, repliedTo: message.replyTo) + return clientMessageID + } + + private func pendingMediaRow( + chip: ComposerChip, + caption: String?, + repliedTo: MessageID?, + clientMessageID: UUID, + date: Date, + status: SendStatus + ) -> ConversationMessage { + // 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 + ) + return ConversationMessage( + id: .unassigned, + senderID: selfUserID, + content: .media([attachment], caption: caption), + date: date, + unreadSeq: 0, + repliedTo: repliedTo, + status: status, + clientMessageID: clientMessageID + ) + } + + /// Records the send in ``pendingMedia`` and keeps its JPEG and stored photo current there as the + /// chip produces them. + private func keepOnDisk( + _ chip: ComposerChip, + clientMessageID: UUID, + conversationID: ConversationID, + createdAt: Date, + caption: String?, + repliedTo: MessageID? + ) { + guard let pendingMedia else { return } + pendingMedia.add(.init(clientMessageID: clientMessageID, conversationID: conversationID, createdAt: createdAt, caption: caption, replyTo: repliedTo)) + chip.persist( + onEncoded: { [weak pendingMedia] data in pendingMedia?.writeImage(data, for: clientMessageID) }, + onStored: { [weak pendingMedia] photo in pendingMedia?.setStored(photo, for: clientMessageID) } + ) + } + + /// Puts the photo sends a killed app left behind back in their transcripts: a send whose bytes + /// never landed as a failed row to retry, one whose bytes had landed resumed. + func restorePendingMedia() { + guard let pendingMedia, let uploaderFor = restoredPhotoUploader else { return } + pendingMedia.sweepOrphans() + + var resuming: [(clientMessageID: UUID, chip: ComposerChip, entry: PendingMediaStore.Entry)] = [] + for entry in pendingMedia.entries { + let conversationID = entry.chatID + let recent = (try? database.messagesWindow(conversationID: conversationID, limit: Self.restoreWindow)) ?? [] + guard let jpeg = pendingMedia.imageData(for: entry), let image = UIImage(data: jpeg) else { + pendingMedia.remove(clientMessageID: entry.clientMessageID) + continue + } + if let stored = entry.stored, alreadySent(stored.blobID, in: recent) { + pendingMedia.remove(clientMessageID: entry.clientMessageID) + continue + } + + let chip = ComposerChip(image: image) + chip.restore(encoded: jpeg, stored: entry.stored, uploader: uploaderFor(conversationID)) + let status: SendStatus = entry.stored == nil ? .failed : .sending + let row = pendingMediaRow( + chip: chip, caption: entry.caption, repliedTo: entry.replyTo, + clientMessageID: entry.clientMessageID, date: entry.createdAt, status: status + ) + let anchor = recent.filter { $0.date <= entry.createdAt }.map(\.id.value).max() ?? 0 + store.insertPending(row, anchoredTo: anchor, into: conversationID) + receiptSettle.hold(entry.clientMessageID.uuidString) + pendingMediaChips[entry.clientMessageID] = chip + chip.persist( + onEncoded: { _ in }, + onStored: { [weak pendingMedia] photo in pendingMedia?.setStored(photo, for: entry.clientMessageID) } + ) + if entry.stored != nil { + resuming.append((entry.clientMessageID, chip, entry)) + } + } + + guard !resuming.isEmpty else { return } + Task { [weak self] in + for send in resuming { + guard let self, let uploader = self.restoredPhotoUploader?(send.entry.chatID) else { return } + let assertion = BackgroundTimeAssertion(assertions: self.backgroundTasks) + assertion.begin() + send.chip.startUpload(using: uploader) + _ = await self.deliverMedia( + clientMessageID: send.clientMessageID, + chip: send.chip, + caption: send.entry.caption, + repliedTo: send.entry.replyTo, + to: send.entry.chatID + ) + assertion.end() + } + } + } + + /// Whether `recent` holds a message this user sent that references `blobID`, so its photo already + /// reached the server. + private func alreadySent(_ blobID: BlobID, in recent: [ConversationMessage]) -> Bool { + recent.contains { message in + guard message.senderID == selfUserID else { return false } + switch message.content { + case .media(let attachments, _): + return attachments.contains { $0.blobID == blobID } + case .text, .cash, .deleted, .encrypted, .widget: + return false + } + } + } + + /// How many of a chat's newest messages ``restorePendingMedia()`` searches for an already-sent photo. + private static let restoreWindow = 100 + + /// 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) + let assertion = BackgroundTimeAssertion(assertions: backgroundTasks) + assertion.begin() + defer { assertion.end() } + _ = 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 { + chip.progress.fail() + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + logger.error("Photo send has no upload to await", metadata: ["conversationID": "\(conversationID)"]) + return false + } + let photo: UploadedPhoto + do { + photo = try await upload.value + } catch { + chip.progress.fail() + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + if let error = error as? ChatMediaUploadError, error.isTerminal { + pendingMedia?.remove(clientMessageID: clientMessageID) + } + 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 + chip.progress.beginSending() + do { + let message = try await messaging.sendMediaMessage( + owner: owner, + conversationID: conversationID, + photo: photo, + caption: caption, + repliedTo: repliedTo, + clientMessageID: clientMessageID + ) + // Before the chip is dropped, so the bubble still holding it fades its overlay out. + chip.progress.finish() + var confirmed = message + confirmed.clientMessageID = clientMessageID + let ok = persist(operation: "send-media-message") { try database.upsertConversationMessages([confirmed], conversationID: conversationID) } + if ok { + keepSentImage(chip.image, clientMessageID: clientMessageID) + store.dropPending(clientMessageID: clientMessageID, confirmedAt: message.id, in: conversationID) + pendingMediaChips[clientMessageID] = nil + pendingMedia?.remove(clientMessageID: clientMessageID) + } else { + scheduleGapCatchUp(conversationID) + } + store.advanceLastActivity(to: message.date, in: conversationID) + refreshFeedPreview(for: conversationID) + persistConversation(conversationID) + Analytics.sentMessage(chatType: chatType) + return true + } catch { + chip.progress.fail() + 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 c60ea7db5..4837f2694 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -9,6 +9,8 @@ import Foundation import os import FlipcashCore +private let logger = Logger(label: "flipcash.encrypted-chat") + /// The chat surface `ConversationController` talks to, with DM end-to-end encryption applied at /// the edge: every message read from the server comes back decrypted (or marked with why it /// couldn't be), and every text sent into an encrypting chat goes out sealed. @@ -135,6 +137,69 @@ extension EncryptedChatClient: ConversationMessaging { } } + /// Sends a photo the way it was uploaded, after checking the chat still decides the same way: a + /// plaintext blob never goes into a chat that encrypts, nor a sealed one into a chat that doesn't. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + try await refetchingOnRefusal(conversationID) { + let seal = try await sealForSending(conversationID, owner: owner) + switch Self.photoSend(photo, seal: seal, conversationID: conversationID) { + case .plain(let blobID): + return try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) + case .sealed(let sealed, let seal): + return try await client.sendSealedMediaMessage(owner: owner, conversationID: conversationID, photo: sealed, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) + case .mismatch: + logger.error("Photo was uploaded for a different encryption choice", metadata: [ + "conversationID": "\(conversationID)", + "sealed": "\(seal != nil)", + ]) + throw ErrorSendMessage.encryptionFailed + } + } + } + + /// How an uploaded photo goes out given the chat's current seal. + enum PhotoSend: Equatable { + /// Plaintext, into a chat that doesn't encrypt. + case plain(BlobID) + /// Sealed, into the chat it was encrypted for. + case sealed(SealedPhoto, ChatSeal) + /// The photo was uploaded for a different encryption choice than the chat now makes. + case mismatch + + static func == (lhs: PhotoSend, rhs: PhotoSend) -> Bool { + switch (lhs, rhs) { + case (.plain(let a), .plain(let b)): a == b + case (.sealed(let a, _), .sealed(let b, _)): a == b + case (.mismatch, .mismatch): true + case (.plain, _), (.sealed, _), (.mismatch, _): false + } + } + } + + /// Matches `photo` to `seal`: a plaintext blob never goes into a chat that encrypts, nor a + /// sealed one into a chat that doesn't or a different chat. + static func photoSend(_ photo: UploadedPhoto, seal: ChatSeal?, conversationID: ConversationID) -> PhotoSend { + switch (photo, seal) { + case (.plain(let blobID), nil): + .plain(blobID) + case (.sealed(let sealed), let seal?) where seal.conversationID == conversationID: + .sealed(sealed, seal) + case (.plain, _?), (.sealed, _): + .mismatch + } + } + + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? { + try await sealForSending(conversationID, owner: owner) + } + + func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? { + switch await opener(conversationID, owner: owner) { + case .seal(let seal): seal + case .awaitingKey, .unsupported: nil + } + } + 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..8946dd06a 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -112,6 +112,15 @@ 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` as one media message, with `caption` under it; a sealed photo + /// goes out inside `EncryptedContent`. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage + /// The seal a photo sent into `conversationID` is encrypted with, nil when it goes out in + /// plaintext. Throws when the chat encrypts but its seal can't be built. + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? + /// The seal `conversationID`'s messages open with, nil when it can't be built yet or the chat + /// doesn't encrypt. + func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? /// 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..4e32be06b --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachCard.swift @@ -0,0 +1,163 @@ +// +// 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? + /// The photo the card is shrinking into the chip, drawn by the surface for the whole landing. + private(set) var landingImage: UIImage? + + /// 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, image: UIImage? = nil) { + landingChipID = chipID + landingChipFrame = nil + landingImage = image + } + + /// 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 + landingImage = 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..a39811645 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -0,0 +1,219 @@ +// +// AttachMenu.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// 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 `+` circle in the composer field's bottom row. 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: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(AttachSurfaceLayout.plusFill, in: Circle()) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .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" + } + } +} + +/// Decides whether a chat offers Camera and Photos. +enum ChatMediaGate { + + /// True for any chat this device has a record of: an encrypted DM's photos are encrypted for it, + /// a plaintext chat's go up in plaintext. False while the record is still loading. + static func acceptsMedia(_ conversation: Conversation?) -> Bool { + conversation != nil + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift new file mode 100644 index 000000000..2acf7fd9d --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift @@ -0,0 +1,141 @@ +// +// 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 + + /// How far left of the composer field the bar's row starts: `$`'s width and gap while it shows, + /// else zero. The menu opens out to there, so it covers the row it opens from. + var menuLeadingReach: CGFloat = 0 + + /// 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 the composer field's, which is + /// the field's padding out from `+`'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 { + // Centred on the field's bottom edge, a short menu tops out just under the field's top and + // leaves its corner showing; lift it to clear the top by `fieldTopOverhang`. + let fieldTop = plusFrame.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + let y = min(plusFrame.maxY - size.height / 2, fieldTop - fieldTopOverhang) + return CGRect(x: plusFrame.minX - BarMetrics.fieldPadding, y: y, width: size.width, height: size.height) + } + + /// How far the open menu reaches above the composer field's top edge. + static let fieldTopOverhang: CGFloat = 8 + + /// 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..9fd0c4281 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachPanel.swift @@ -0,0 +1,119 @@ +// +// 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 finished: logically for an open, fully settled for a close, so the surface is not + /// taken down before it has reached `+`. + func animate(_ change: (AttachPanel) -> Void) { + let closes = isOpen + withAnimation(ChatMotion.attachPanel.animation, completionCriteria: closes ? .removed : .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..06f1580e3 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -0,0 +1,359 @@ +// +// 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 { + + /// Matches the composer field's corner, so the menu reads as part of it. + static let menuCornerRadius: CGFloat = BarMetrics.fieldCornerRadius + /// `+`'s flat fill on the composer field, which the surface turns into as it reaches `+`. + static let plusFill = Color.white.opacity(0.10) + /// How far past `+`'s size the surface has fully turned from `+`'s fill into the panel's glass. + static let plusBlendDistance: 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: room for an icon and a short label, well short of the send button. + static let menuWidth: CGFloat = 220 + /// A menu row's height, sized to the system's own attachment menus. + static let menuRowHeight: CGFloat = 62 + static let menuVerticalPadding: CGFloat = 10 + /// The rows' blur and scale while hidden, which they sharpen and grow out of as the menu opens. + static let rowsBloomBlur: CGFloat = 8 + static let rowsBloomScale: CGFloat = 0.85 + + /// 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`. Either way its + /// leading edge is the composer field's, ``BarMetrics/fieldPadding`` out from `+`'s, moved out a + /// further `leadingReach` at the same width; standing on `+`, its bottom edge is the field's too. + static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement, leadingReach: CGFloat = 0) -> CGRect { + let rect = fieldAlignedMenuRect(plus: plus, size: size, placement: placement) + return CGRect(x: rect.minX - leadingReach, y: rect.minY, width: rect.width, height: rect.height) + } + + private static func fieldAlignedMenuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { + switch placement { + case .standsOnPlus: + CGRect(x: plus.minX - BarMetrics.fieldPadding, y: plus.maxY + BarMetrics.fieldPadding - 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) + } + + /// Returns the radius of the surface collapsed onto `plus`, which is a circle: half its height. + static func collapsedCornerRadius(plus: CGRect) -> CGFloat { + plus.height / 2 + } + + /// 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: collapsedCornerRadius(plus: plus)) + 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, + leadingReach: model.overKeyboard.menuLeadingReach + ) + 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, plus: plus, blendsIntoPlus: phase != .landing) { 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) + // From the hand-off on, filling the surface at every step: the viewfinder holds the + // capture, and it collapses into the chip rather than an empty surface flying there. + if let image = model.attachCard.landingImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: rect.width, height: rect.height) + .clipped() + .allowsHitTesting(false) + .accessibilityHidden(true) + .transition(.identity) + } + rows(phase: phase, menu: menu, motion: motion) + .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: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: plus.width, height: plus.height) + .accessibilityHidden(true) + } + + /// The rows sharpen out of a blur and grow from `+` as the surface opens, and go back into it as + /// it closes, so they read as poured out of `+` rather than faded over it. + private func rows(phase: AttachSurfacePhase, menu: CGRect, motion: AttachMotion) -> some View { + let panel = model.attachPanel + let shown = phase.showsRows + let blooms = motion.animatesGeometry + let anchor = UnitPoint( + x: menu.width > 0 ? (plus.midX - menu.minX) / menu.width : 0, + y: menu.height > 0 ? (plus.midY - menu.minY) / menu.height : 1 + ) + 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 } + .animation(ChatMotion.attachPanel.animation) { + $0.blur(radius: shown || !blooms ? 0 : AttachSurfaceLayout.rowsBloomBlur) + .scaleEffect(shown || !blooms ? 1 : AttachSurfaceLayout.rowsBloomScale, anchor: anchor) + } + .attachLayer(isShown: shown) + } + + /// 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 + /// `+`'s frame, which the surface takes on the look of as it shrinks to `+`'s size. + let plus: CGRect + /// Whether the surface turns into `+`'s fill near `+`'s size; not while it lands on a chip. + let blendsIntoPlus: Bool + 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) + let glass = glassAmount(at: rect) + ZStack(alignment: .topLeading) { + clip + .fill(AttachSurfaceLayout.plusFill) + .frame(width: rect.width, height: rect.height) + .opacity(1 - glass) + Color.clear + .frame(width: rect.width, height: rect.height) + .panelGlassBackground(cornerRadius: shape.cornerRadius) + .opacity(glass) + content(rect) + } + .frame(width: rect.width, height: rect.height, alignment: .topLeading) + .clipShape(clip) + .contentShape(clip) + .offset(x: rect.minX, y: rect.minY) + } + + /// How much of the panel's glass shows at `rect`, against `+`'s flat fill: none at `+`'s size, + /// all of it once the surface is a few dozen points bigger, so the two meet without a colour jump. + private func glassAmount(at rect: CGRect) -> CGFloat { + guard blendsIntoPlus else { return 1 } + let growth = max(rect.width - plus.width, rect.height - plus.height) + return min(max(growth / AttachSurfaceLayout.plusBlendDistance, 0), 1) + } +} 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..617515449 --- /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 dark-tinted glass. +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) + .overlayGlassBackground(in: Circle()) + .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 ea9dbe9a2..16d4ce9e3 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -151,7 +151,7 @@ extension ChatItem { func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool { switch message.content { case .text(let text): EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted, .widget: false + case .cash, .deleted, .encrypted, .widget, .media: false } } func rendersBare(_ message: ConversationMessage) -> Bool { @@ -164,7 +164,7 @@ extension ChatItem { let layouts = messages.map { message in switch message.content { case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard)) - case .cash, .deleted, .encrypted, .widget: [RowLayout(part: nil, text: nil, preview: nil)] + case .cash, .deleted, .encrypted, .widget, .media: [RowLayout(part: nil, text: nil, preview: nil)] } } // A status line under a message sits between it and the next bubble, so it ends the bubble @@ -263,6 +263,19 @@ extension ChatItem { isFromSelf: isFromSelf, senderName: counterpartName )) + 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, + sealed: attachment?.sealed + )) } // The status line rides on the bubble itself (not a separate row, so a send is a clean @@ -428,6 +441,15 @@ extension ChatItem { kind: .unavailable, authorID: original.senderID ) + case .media(let attachments, let caption): + // A redacted original's bytes may not be fetched, so its quote carries no thumbnail. + return ChatQuote( + stableID: original.stableID, + authorName: authorName, + snippet: ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), + kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID, sealed: attachments.first?.sealed), + authorID: original.senderID + ) case .encrypted: // No plaintext to preview -- same unavailable treatment as a quote whose original the // local database never saw. 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..f0770f1db --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift @@ -0,0 +1,383 @@ +// +// ChatMediaUploader.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import ImageIO +import FlipcashCore +import FlipcashUI + +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. `onProgress` hears + /// the bytes going out, from any thread. + func storeBlob(_ data: Data, mimeType: String, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID + + /// Stores `image` end-to-end encrypted with `seal` for its DM and returns the blob, before the + /// server has finalized it. `onProgress` hears the sealed bytes going out, from any thread. + func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload + + /// 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, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID { + try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair, onProgress: onProgress) + } + + func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload { + try await flipClient.storeEncryptedBlob(image, seal: seal, owner: session.ownerKeyPair, onProgress: onProgress) + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) + } +} + +/// A chat photo uploaded and finalized, in the form its message references it. +enum UploadedPhoto: Hashable, Sendable, Codable { + /// A plaintext blob, whose metadata the server derives. + case plain(BlobID) + /// A blob end-to-end encrypted for the chat, with the metadata its sealed message carries. + case sealed(SealedPhoto) + + /// The finalized blob. + var blobID: BlobID { + switch self { + case .plain(let blobID): blobID + case .sealed(let photo): photo.blobID + } + } +} + +/// Why a chat photo did not upload. +enum ChatMediaUploadError: Error { + /// The upload policy accepts no JPEG. + case noMatchingConstraint + /// The upload policy allows the owner no end-to-end encrypted upload. + case encryptionNotAllowed + /// The chat's seal could not be built, so the photo cannot be encrypted for it. + case sealUnavailable(Error) + /// 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 the send can never succeed, so nothing about it is worth keeping: the server refused + /// the bytes, or the photo can't be uploaded again. + var isTerminal: Bool { + switch self { + case .rejected: + true + case .noMatchingConstraint, .encryptionNotAllowed, .sealUnavailable, .encodingFailed, .failed: + !isRetryable + } + } + + /// Whether uploading the same photo again could succeed. + var isRetryable: Bool { + switch self { + case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed: + false + case .sealUnavailable(let error): + // A peer key that couldn't be fetched may arrive; one shared-core refuses never will. + error is PeerKeyUnavailable + case .rejected(let reason): + switch reason { + case .moderation: + false + case .unsupportedType, .mismatchedType, .tooLarge, .corrupt, .privacyMetadata, .internal, .unknown, .unrecognized: + true + } + case .failed: + true + } + } +} + +extension ChatMediaUploadError: ServerError { + var reportingLevel: ErrorReportingLevel { + switch self { + case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed: + .error + case .sealUnavailable(let error): + error is PeerKeyUnavailable ? .info : .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. In a chat +/// that encrypts, the bytes are encrypted for it before they leave the device. +struct ChatMediaUploader { + + /// The seal to encrypt a photo with, or nil when the chat takes plaintext photos. + typealias SealProvider = @MainActor () async throws -> ChatSeal? + + let blob: any ChatMediaBlobStoring + + /// Decides at upload time whether the photo is encrypted, the same decision text sends make. + var seal: SealProvider = { nil } + + /// 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 photo for `image`, throwing `ChatMediaUploadError`. + /// + /// `onPrepared` receives the uploaded pixel width and height once, before any bytes are stored. + /// `onEncoded` receives the JPEG that will be stored, before it is, so the caller can keep the + /// exact bytes. `onStored` receives the photo once its bytes are stored, before the server has + /// finalized it, so a failed wait can resume through ``finalize(_:progress:)`` instead of + /// storing again. `progress`, when given, follows each store attempt's bytes and then the + /// server's processing. + func upload( + _ image: UIImage, + progress: ChatPhotoSendProgress? = nil, + onStored: (UploadedPhoto) -> Void = { _ in }, + onPrepared: (Int, Int) -> Void, + onEncoded: (Data) -> Void = { _ in } + ) async throws -> UploadedPhoto { + let (chatSeal, bounds, maxSizeBytes) = try await resolveConstraints() + guard let cgImage = image.cgImage, maxSizeBytes > 0 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: bounds?.maxWidth ?? 0, + maxHeight: bounds?.maxHeight ?? 0, + maxPixels: bounds?.maxPixels ?? 0 + ) + onPrepared(target.width, target.height) + + let data: Data + do { + data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) + } catch let error as ChatMediaEncoder.Error { + throw ChatMediaUploadError.encodingFailed(error) + } + onEncoded(data) + + return try await storeAndFinalize(data, width: target.width, height: target.height, chatSeal: chatSeal, progress: progress, onStored: onStored) + } + + /// Returns the finalized photo for `jpeg`, bytes an earlier ``upload(_:progress:onStored:onPrepared:onEncoded:)`` + /// produced, stored as they are rather than encoded again; throws `ChatMediaUploadError`. + func upload( + jpeg: Data, + progress: ChatPhotoSendProgress? = nil, + onStored: (UploadedPhoto) -> Void = { _ in }, + onPrepared: (Int, Int) -> Void + ) async throws -> UploadedPhoto { + let (chatSeal, _, _) = try await resolveConstraints() + guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int else { + throw ChatMediaUploadError.encodingFailed(.encodingFailed) + } + onPrepared(width, height) + return try await storeAndFinalize(jpeg, width: width, height: height, chatSeal: chatSeal, progress: progress, onStored: onStored) + } + + /// The seal for this photo and the policy's bounds and size ceiling for the form it goes out in. + private func resolveConstraints() async throws -> (ChatSeal?, UploadPolicy.ImageConstraints?, Int) { + let chatSeal: ChatSeal? + do { + chatSeal = try await seal() + } catch { + try Task.checkCancellation() + logger.info("No seal for chat photo", metadata: ["error": "\(error)"]) + throw ChatMediaUploadError.sealUnavailable(error) + } + + let policy: UploadPolicy + do { + policy = try await blob.uploadPolicy() + } catch { + try Task.checkCancellation() + throw ChatMediaUploadError.failed(error) + } + + if chatSeal != nil { + guard let encrypted = policy.encrypted else { + logger.warning("Upload policy allows no encrypted chat photo", metadata: ["policyVersion": "\(policy.version)"]) + throw ChatMediaUploadError.encryptionNotAllowed + } + // The ceiling covers the sealed blob, so the image must leave room for nonce and tag. + return (chatSeal, encrypted.image, encrypted.maxSizeBytes - EncryptedBlobUpload.overhead) + } else { + guard let constraint = policy.constraint(for: ChatMediaEncoder.mimeType) else { + logger.warning("Upload policy accepts no chat photo", metadata: ["policyVersion": "\(policy.version)"]) + throw ChatMediaUploadError.noMatchingConstraint + } + return (nil, constraint.image, constraint.maxSizeBytes) + } + } + + private func storeAndFinalize( + _ data: Data, + width: Int, + height: Int, + chatSeal: ChatSeal?, + progress: ChatPhotoSendProgress?, + onStored: (UploadedPhoto) -> Void + ) async throws -> UploadedPhoto { + // Byte counts arrive on URLSession's delegate queue. + let onBytes: @Sendable (BlobUploadProgress) -> Void = { [weak progress] bytes in + Task { @MainActor in progress?.didUpload(bytes) } + } + + let uploaded: UploadedPhoto + if let chatSeal { + let blurhash = await Self.blurhash(of: data) + let stored = try await store(progress: progress) { try await blob.storeEncryptedBlob(data, seal: chatSeal, onProgress: onBytes) } + uploaded = .sealed(SealedPhoto( + blobID: stored.blobID, + mimeType: ChatMediaEncoder.mimeType, + sizeBytes: stored.plaintextSize, + width: width, + height: height, + blurhash: blurhash + )) + } else { + uploaded = .plain(try await store(progress: progress) { try await blob.storeBlob(data, mimeType: ChatMediaEncoder.mimeType, onProgress: onBytes) }) + } + + onStored(uploaded) + return try await finalize(uploaded, progress: progress) + } + + /// Returns `photo` once the server has finalized its stored bytes, throwing + /// `ChatMediaUploadError`. + func finalize(_ photo: UploadedPhoto, progress: ChatPhotoSendProgress? = nil) async throws -> UploadedPhoto { + progress?.beginProcessing() + + do { + try await blob.awaitBlobFinalization(blobID: photo.blobID) + } catch ErrorBlob.rejected(let reason) { + throw ChatMediaUploadError.rejected(reason) + } catch { + try Task.checkCancellation() + throw ChatMediaUploadError.failed(error) + } + + return photo + } + + /// Stores `data`, retrying through `backoff` while the failure is in transit. + private func store(progress: ChatPhotoSendProgress?, _ attemptStore: () async throws -> Stored) async throws -> Stored { + var attempt = 0 + while true { + // A retried store sends every byte again. + progress?.beginAttempt() + do { + return try await attemptStore() + } 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) + } + + /// The BlurHash of the encoded photo, which a recipient of an encrypted photo draws until it + /// decrypts, since the server cannot derive one. Empty when the bytes don't decode, which the + /// contract allows. + @concurrent + nonisolated static func blurhash(of jpeg: Data) async -> String { + guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil), + let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 64, + ] as CFDictionary) else { return "" } + let landscape = thumbnail.width >= thumbnail.height + return BlurHash.encode(thumbnail, componentsX: landscape ? 4 : 3, componentsY: landscape ? 3 : 4) ?? "" + } +} + +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..7b79c123c --- /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) + .overlayGlassBackground(in: Circle()) + .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) + .overlayGlassBackground(in: Capsule()) + .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..d040c30c8 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,23 @@ 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 } + /// Decrypts the chat's end-to-end encrypted photo blobs, or nil while its key is unknown. + var mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? = { 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 +161,16 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource + screen.onMediaTap = onMediaTap + context.coordinator.mintMediaURL = mintMediaURL + context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt + screen.mediaURLResolver = context.coordinator.mediaURLResolver + screen.pendingMediaImage = { [conversationController] id in + conversationController.pendingMediaImage(forMessageID: id) + } + screen.pendingMediaProgress = { [conversationController] id in + conversationController.pendingMediaProgress(forMessageID: id) + } screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite @@ -189,6 +217,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource + screen.onMediaTap = onMediaTap + context.coordinator.mintMediaURL = mintMediaURL + context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite @@ -269,7 +300,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 +326,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. + quoteThumbnailLocation: { [resolver = coordinator.mediaURLResolver] kind in + await resolver.thumbnailLocation(for: kind, canReact: true) + } ) .environment(conversationController) .modifier( @@ -298,15 +367,95 @@ 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 + } + let chip = composer.chips.first { $0.id == chipID } + card.beginLanding(on: chipID, image: chip.map { $0.preview ?? $0.image }) + 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. + /// The latest ``ChatScreenRepresentable/mediaBlobDecrypt``, read by the resolver. + var mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? = { nil } + lazy var mediaURLResolver = ChatMediaURLResolver( + fetch: { [weak self] blobID in + guard let self else { return nil } + return try await self.mintMediaURL(blobID) + }, + decrypt: { [weak self] in + await self?.mediaBlobDecrypt() + } + ) } } @@ -325,13 +474,14 @@ private struct MeasuredBarHeight: ViewModifier { content .fixedSize(horizontal: false, vertical: true) .overlayPreferenceValue(BarAccessoriesKey.self) { accessories in - GeometryReader { proxy in - Color.clear.onChange( - of: BarReport(height: proxy.size.height, accessories: accessories), - initial: true - ) { _, new in - report(new.height, new.accessories) - } + // `onGeometryChange`, not a `GeometryReader`: under an animated layout change the + // reader reports the height the animation starts from and then goes quiet, which + // leaves the clip short of the content it settles at. + Color.clear.onGeometryChange( + for: BarReport.self, + of: { BarReport(height: $0.size.height, accessories: accessories) } + ) { new in + report(new.height, new.accessories) } } // Sit on the host's bottom edge rather than in the middle of it. The two heights are @@ -345,7 +495,7 @@ private struct MeasuredBarHeight: ViewModifier { } /// One measurement of the bar: its height and the cards that make it up. -private struct BarReport: Equatable { +private nonisolated struct BarReport: Equatable, Sendable { let height: CGFloat let accessories: BarAccessories } diff --git a/Flipcash/Core/Screens/Conversation/ComposerChip.swift b/Flipcash/Core/Screens/Conversation/ComposerChip.swift new file mode 100644 index 000000000..16cc726a3 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -0,0 +1,159 @@ +// +// ComposerChip.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import Observation +import FlipcashCore +import FlipcashUI + +/// 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 + + /// How far this photo's send has got, which its transcript bubble draws. + let progress = ChatPhotoSendProgress() + + /// 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? + + /// The photo whose bytes storage already holds, so a retry waits on the server again instead of + /// storing a second copy. Nil until a store succeeds, and again once the server rejects it. + @ObservationIgnored private var stored: UploadedPhoto? + + /// The JPEG the upload encoded, kept so a retry stores the same bytes instead of encoding again. + @ObservationIgnored private var encoded: Data? + + /// Hears the encoded JPEG as soon as it exists; see ``persist(onEncoded:onStored:)``. + @ObservationIgnored private var onEncoded: ((Data) -> Void)? + + /// Hears the stored photo as soon as its bytes land; see ``persist(onEncoded:onStored:)``. + @ObservationIgnored private var onStored: ((UploadedPhoto) -> Void)? + + init(image: UIImage, preview: UIImage? = nil) { + self.image = image + self.preview = preview + } + + /// Hands the encoded JPEG and the stored photo to the callbacks, now if the chip already has + /// them and otherwise when they arrive, so a send can keep them on disk. + func persist(onEncoded: @escaping (Data) -> Void, onStored: @escaping (UploadedPhoto) -> Void) { + self.onEncoded = onEncoded + self.onStored = onStored + if let encoded { onEncoded(encoded) } + if let stored { onStored(stored) } + } + + /// Rebuilds a chip for a send read back from disk: `encoded` is the JPEG that was held and + /// `stored` the photo whose bytes had landed, if they had. With nothing stored the chip starts + /// failed and retryable; otherwise ``startUpload(using:)`` resumes by waiting on the server. + func restore(encoded: Data, stored: UploadedPhoto?, uploader: ChatMediaUploader) { + self.encoded = encoded + self.stored = stored + self.uploader = uploader + if stored == nil { + state = .failed(.retryable) + progress.fail() + } + } + + /// 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 + progress.beginAttempt() + + uploadTask = Task { + do { + let photo: UploadedPhoto + if let stored { + state = .uploading + photo = try await uploader.finalize(stored, progress: progress) + } else if let encoded { + photo = try await uploader.upload(jpeg: encoded, progress: progress, onStored: { photo in + stored = photo + onStored?(photo) + }, onPrepared: { width, height in + preparedWidth = width + preparedHeight = height + state = .uploading + }) + } else { + photo = try await uploader.upload(image, progress: progress, onStored: { photo in + stored = photo + onStored?(photo) + }, onPrepared: { width, height in + preparedWidth = width + preparedHeight = height + state = .uploading + }, onEncoded: { data in + encoded = data + onEncoded?(data) + }) + } + if !Task.isCancelled { + state = .uploaded(photo.blobID) + } + return photo + } catch let error as ChatMediaUploadError { + if case .rejected = error { + stored = nil + } + if !Task.isCancelled { + state = .failed(error.isRetryable ? .retryable : .notRetryable) + progress.fail() + } + 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..470bacdeb --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -0,0 +1,185 @@ +// +// 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 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 thumbnail stands alone: uploaded, or uploading quietly until the send, whose bubble + /// shows the progress. + case none + + /// Returns the badge for a chip in `state`. + init(_ state: ComposerChip.State) { + switch state { + case .preparing, .uploading, .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 .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 1a60664c9..2b0034e58 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 thumbnailLocation: (ChatQuote.Kind) async -> ChatMediaLocation? = { _ in nil } let onDismiss: () -> Void @Environment(\.barCardCollapsed) private var collapsed + @State private var resolvedThumbnail: ChatMediaLocation? + /// 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. @@ -137,7 +147,7 @@ struct ComposerReplyStrip: View { .foregroundStyle(Color.textSecondary) } .lineLimit(1) - case .text, .unavailable: + case .text, .media, .unavailable: Text(target.snippet) .font(.default(size: 14, weight: .medium)) .foregroundStyle(Color.textMain) @@ -145,12 +155,39 @@ 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: ChatMediaImageSource.source(blobID: thumbnailBlobID, location: resolvedThumbnail)) + .targetCache(ChatMediaImageSource.cache) + .onSuccess { ChatMediaImageSource.persist(.success($0)) } + .resizable() + .scaledToFill() + } + } + .frame(width: Self.thumbnailSide, height: Self.thumbnailSide) + .clipShape(.rect(cornerRadius: 6)) + .accessibilityHidden(true) + .task(id: thumbnailBlobID) { + resolvedThumbnail = await thumbnailLocation(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 { switch target.kind { case .cash(let token, _): "\(target.snippet) \(token)" - case .text, .unavailable: target.snippet + case .text, .media, .unavailable: target.snippet } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 4d5c6a6ad..88e09ed97 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 @@ -31,12 +155,19 @@ private let replySpring = ChatMotion.replySurface.animation /// heights and corners can't desync. Deliberately not `Metrics.buttonHeight`/`buttonRadius` — beside /// the field the controls are field-sized, not standard-button-sized. enum BarMetrics { - static let fieldMinHeight: CGFloat = 34 - static let fieldVerticalPadding: CGFloat = 8 + nonisolated static let fieldMinHeight: CGFloat = 34 + nonisolated static let fieldVerticalPadding: CGFloat = 8 + /// The field's padding on every side: where `+` stands in from the field's leading edge, and so + /// how far the open attach menu reaches back past `+` to line up with the field. + nonisolated static let fieldPadding: CGFloat = 8 static let cornerRadius: CGFloat = 14 + /// The composer field's corner, rounder than the bar's other controls. + nonisolated static let fieldCornerRadius: CGFloat = 28 /// The height of every bar control: a single-line field plus its padding, and the height the /// Send Cash button morphs at while there is a composer beside it. - static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + nonisolated static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + /// The diameter of the controls beside the text: `+`, the Send Cash button and the send button. + nonisolated static let accessorySize: CGFloat = 34 /// The bar's own margin around its controls, above and below. static let contentPadding: CGFloat = 8 /// The margin between the bar's controls and the screen's sides while the keyboard is up. @@ -47,7 +178,58 @@ enum BarMetrics { static let compactInset: CGFloat = 32 } -/// The unified bottom bar: Send Cash (morphing) beside the message field. +/// What stands outside the message field, beside it. +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 round `$` beside the field once the chat exists, shown while the draft is empty. + case cash + /// Nothing beside the field. + case none + + /// Returns the control for the bar's state. An edit wins; otherwise Send Cash, as the call to + /// action before the chat exists and as the round `$` after. + init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool) { + if isEditing { + self = .cancelEdit + } else if !showsSendCash { + self = .none + } else { + self = chatExists ? .cash : .sendCash + } + } +} + +/// What the field's row holds beside the send button: the `+` menu. +struct ConversationBarBottomRow: Equatable { + /// The rows of the `+` menu; empty hides `+`. Never holds Cash, which the `$` beside the field + /// takes over. + let plusItems: [AttachMenuItem] + + /// A row with nothing in it. + static let empty = ConversationBarBottomRow(plusItems: []) + + /// Whether there is nothing for `+` to open. + var isEmpty: Bool { plusItems.isEmpty } + + /// Returns the row for the bar's state: empty during an edit and before the chat exists; + /// otherwise `+` while it has a row to open. + init(isEditing: Bool, chatExists: Bool, acceptsMedia: Bool, attachedCount: Int) { + guard !isEditing, chatExists else { + self.init(plusItems: []) + return + } + self.init(plusItems: AttachMenuItem.items(showsCash: false, acceptsMedia: acceptsMedia, attachedCount: attachedCount)) + } + + private init(plusItems: [AttachMenuItem]) { + self.plusItems = plusItems + } +} + +/// 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,9 +270,48 @@ 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 round `$` shows: while the draft is empty. Set in a transaction after the text + /// update, so the split animates without carrying the field's text change with it. + @State private var cashIsShown = true + + /// How far left of the field the row starts, for the attach menu to open out to. + private var menuLeadingReach: CGFloat { + switch leadingControl { + case .cash: + cashIsShown ? BarMetrics.contentHeight + Self.leadingSpacing : 0 + case .cancelEdit, .sendCash, .none: + 0 + } + } + @Namespace private var composerGlassNamespace + /// 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 quoteThumbnailLocation: (ChatQuote.Kind) async -> ChatMediaLocation? = { _ 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) + /// `$` splitting from the field's glass and joining back into it. + private static let cashSpring = Animation.spring(duration: 0.4, bounce: 0.3) + /// The gap between the leading control and the field. The composer's glass joins across no + /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. + static let leadingSpacing: CGFloat = 10 /// Whether the bar sits inset from the screen's sides: at rest with the keyboard down. It widens /// to the full edge inset as the keyboard comes up. Only once there is a composer; the pre-chat @@ -100,6 +321,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. @@ -124,40 +347,116 @@ struct ConversationBottomBar: View { } } + /// The glass behind the leading control and the field, drawn as one layer apart from them so + /// `$` splits from the field and joins back into it. The real controls sit above it, outside the + /// container: in one, the glass composites above sibling content and covers the typed text. + @ViewBuilder + private var composerGlass: some View { + let field = RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous) + let layout = HStack(alignment: .bottom, spacing: Self.leadingSpacing) { + switch leadingControl { + case .cancelEdit: + // The button's size, so the field's glass starts where the field does. + Color.clear.frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + case .cash: + if cashIsShown { + Color.clear + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .composerGlass(in: Circle(), id: "cash", namespace: composerGlassNamespace) + } + case .sendCash, .none: + EmptyView() + } + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .composerGlass(in: field, id: "field", namespace: composerGlassNamespace) + } + if #available(iOS 26, *) { + GlassEffectContainer(spacing: Self.leadingSpacing) { layout } + } else { + layout + } + } + private var composerBar: some View { // Bottom-aligned, against the bar's own pinned bottom: the field is the side that grows, and // top-aligning the control beside it made the control travel with every line the draft // 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 - ) - } - if chatExists { - ConversationComposer(conversationID: conversationID, model: model, composer: composer) + let motion = AttachMotion(reduceMotion: reduceMotion) + let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { + HStack(alignment: .bottom, spacing: Self.leadingSpacing) { + // An edit takes over the bar: the leading control becomes the way out of it and the + // field's bottom row steps aside until it resolves, the way WhatsApp hides its accessory controls. + switch leadingControl { + case .cancelEdit: + CancelEditButton { composer.endEditing() } + case .sendCash: + SendCashMorphButton( + symbol: symbol, + // Minimized by a reply as well as by focus. Starting a reply from the context + // menu raises the keyboard, and focus — and so `isComposing` — arrives a + // transaction later than the reply target, on its own bouncy spring: the button + // collapsed after the bar had already grown, jolting the field beside it. + // Reading the target directly puts the morph in the reply's own transaction, so + // the two move together and the later focus change finds nothing left to do. + composing: model.isComposing || composer.replyTarget != nil, + // Before the first tip there is no composer to sit beside: the design draws + // the full-width "Start Chatting" CTA (node 10074:18891). + standalone: true, + expandedTitle: isTipDm ? startChattingTitle : nil, + action: onSendCash + ) + case .cash: + if cashIsShown { + ComposerCashButton(symbol: symbol, action: onSendCash) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } + case .none: + EmptyView() + } + if chatExists { + ConversationComposer( + conversationID: conversationID, + model: model, + composer: composer, + bottomRow: bottomRow, + hidesPlus: showsCard, + // Swapped out in one frame for the surface, which is drawn as `+` where it stands. + plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onAttachOpen: onAttachOpen + ) + // Over the row's other controls, which the attach panel floats across. + .zIndex(1) .transition(.opacity) + } + } + .background { + if chatExists { + composerGlass + } + } + // A draft already there when the chat opens hides `$` without animating it out. + .onAppear { cashIsShown = composer.draft.isEmpty } + .onChange(of: composer.draft.isEmpty) { _, isEmpty in + guard cashIsShown != isEmpty else { return } + withAnimation(Self.cashSpring) { cashIsShown = isEmpty } + } + .onChange(of: menuLeadingReach, initial: true) { _, reach in + model.overKeyboard.menuLeadingReach = reach + } + // 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. Left visible, not faded: the + // card grows over it and shrinks back onto it, so it is never seen missing. 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, !bottomRow.plusItems.isEmpty { + attachSurface(items: bottomRow.plusItems) + } } } // The Send Cash button morphs on the reply's spring. Inside the row's padding, so it covers @@ -171,6 +470,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 +542,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, thumbnailLocation: quoteThumbnailLocation) { composer.endReplying() } } // The quote narrows with the row below it, so the two keep one margin. .padding(.horizontal, compactExtraInset) @@ -258,6 +597,75 @@ 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 + return showsCamera || (model.attachPanel.isOpen && bottomRow.plusItems.contains(.camera)) + } + + private var leadingControl: ConversationBarLeadingControl { + ConversationBarLeadingControl( + isEditing: composer.isEditing, + chatExists: chatExists, + showsSendCash: showsSendCash + ) + } + + private var bottomRow: ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: composer.isEditing, + chatExists: chatExists, + 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. @@ -626,65 +1034,199 @@ struct ConversationComposer: View { let conversationID: ConversationID? @Bindable var model: ConversationBarModel @Bindable var composer: ComposerModel + /// What the row holds beside the text: `+`. + var bottomRow = ConversationBarBottomRow.empty + /// Whether `+` is hidden under the camera or photo card. + var hidesPlus = false + /// Whether the attach surface is drawn in `+`'s place, which hides `+`. + var plusIsStoodInFor = false + /// Fired as `+` opens the attach panel with the given rows. + var onAttachOpen: ([AttachMenuItem]) -> Void = { _ in } @Environment(ConversationController.self) private var conversationController @FocusState private var isFocused: Bool /// Send button scale-in/out as text appears/clears. private static let sendButtonSpring = ChatMotion.sendButton.animation + /// The text's and chips' inset from the field's leading edge. + private static let leadingInset: CGFloat = 14 + /// The stacked text's inset from the field's leading edge, matched to its inset from the top so + /// the first line sits evenly in the corner. + private static let stackedLeadingInset: CGFloat = 12 + /// The gap between the row's controls and the text. + private static let controlSpacing: CGFloat = 8 + /// The move between the one-row and stacked layouts. + /// The clip around the bar follows a shrink on `replySurface`, so the glass shares it. + private static let stackSpring = ChatMotion.replySurface.animation + + /// Whether the text spans the field with the controls in a row under it. + @State private var isStacked = false + /// The text's own layout, switched without animation: an animated width re-lays the text + /// every frame, and UIKit chases each new caret position on its own timing. + @State private var textIsStacked = false + /// The text's offset from its new place, sprung back to zero so it travels as one piece. + @State private var textShift: CGFloat = 0 + /// Whether send is up; follows `showsSubmit` in its own transaction so the pop animates. + @State private var submitIsShown = false + /// The text field's width while it sits inline between the controls. + /// The row's width, which stacking does not change, so the inline text width can be derived + /// in either layout rather than measured from a field that is about to move. + @State private var rowWidth: CGFloat = 0 + /// The draft's width laid out on one line. + @State private var draftLineWidth: CGFloat = 0 + + private var hasPlus: Bool { !bottomRow.plusItems.isEmpty } + + private var inlineLeadingPadding: CGFloat { + hasPlus ? BarMetrics.accessorySize + Self.controlSpacing : Self.leadingInset - BarMetrics.fieldPadding + } + + private func textLeadingPadding(stacked: Bool) -> CGFloat { + stacked ? Self.stackedLeadingInset - BarMetrics.fieldPadding : inlineLeadingPadding + } + + private var inlineTrailingPadding: CGFloat { BarMetrics.accessorySize + Self.controlSpacing } + + /// Stacks once the draft wraps or takes a newline, and unstacks only when it is cleared, so + /// deleting back under one line does not bounce the layout. + private func updateStacking() { + let stacked = Self.stacks( + draft: composer.draft, + wasStacked: textIsStacked, + draftLineWidth: draftLineWidth, + inlineWidth: rowWidth > 0 ? rowWidth - inlineLeadingPadding - inlineTrailingPadding : 0 + ) + // Its own transaction, after the text update has landed: the whole bar follows the field up + // or down, while the text update itself stays unanimated. + guard stacked != textIsStacked else { return } + let shift = textLeadingPadding(stacked: textIsStacked) - textLeadingPadding(stacked: stacked) + var snap = Transaction() + snap.disablesAnimations = true + withTransaction(snap) { + textIsStacked = stacked + textShift = shift + } + // A frame later, so the shift has rendered before it springs back with the bar. + Task { @MainActor in + withAnimation(Self.stackSpring) { + isStacked = stacked + textShift = 0 + } + } + } + + /// Whether the composer stacks for `draft`, given whether it was stacked and the draft's + /// one-line width against the inline field's. + nonisolated static func stacks(draft: String, wasStacked: Bool, draftLineWidth: CGFloat, inlineWidth: CGFloat) -> Bool { + guard !draft.isEmpty else { return false } + if wasStacked || draft.contains(where: \.isNewline) { return true } + return inlineWidth > 0 && draftLineWidth > inlineWidth + } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - let field = HStack(alignment: .bottom, spacing: 10) { - TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) - .font(.appTextMessage) - .foregroundStyle(Color.textMain) - .tint(.white) - .lineLimit(1...5) - .focused($isFocused) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: BarMetrics.fieldMinHeight) - // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the - // moment there is a draft, so a test that types and then reads the field back finds - // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a - // text-field automation type, so the query has to be identifier-based, not type-based. - .accessibilityIdentifier("composer-message-field") - - // The spring is scoped to the button, not to the row. On the row it took the field - // into the transaction as well, and `showsSubmit` falls on the same update that empties - // the draft — so the field's text update ran as an animated one against its text view, - // where it can be coalesced away. That leaves the sent text on screen with the binding - // already empty, and an unchanged binding never pushes it again. - Group { - if showsSubmit { - Button(action: submit) { - Image(systemName: submitSymbol) - .font(.default(size: 16, weight: .bold)) - .foregroundStyle(Color.textAction) - .frame(width: 34, height: 34) - .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) - // Arrow and checkmark are the same button in two jobs, so the glyph swaps in - // place rather than the button popping out and a new one popping back. - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - .accessibilityLabel(composer.isEditing ? "Save" : "Send") - .accessibilityIdentifier("send-message-button") - // Pop from 60% + fade, so the opacity ramp actually reads - // (scaling from 0 hides the fade behind a tiny speck). - .transition(.scale(scale: 0.6).combined(with: .opacity)) + let motion = AttachMotion(reduceMotion: reduceMotion) + let textField = TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + .tint(.white) + .lineLimit(1...5) + .focused($isFocused) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: BarMetrics.fieldMinHeight) + // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the + // moment there is a draft, so a test that types and then reads the field back finds + // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a + // text-field automation type, so the query has to be identifier-based, not type-based. + .accessibilityIdentifier("composer-message-field") + + // One row: `+`, the text, `$` until there is a draft, and send. Once the text wraps or takes + // a newline the row stacks: the text spans the field and the controls drop to a row under + // it, until the draft is cleared. The text field never moves in the hierarchy, only its + // insets do, so it keeps focus and the keyboard through the change. + let controls = HStack(alignment: .bottom, spacing: 0) { + if hasPlus { + AttachMenu( + items: bottomRow.plusItems, + panel: model.attachPanel, + hidesButton: hidesPlus, + isStoodInFor: plusIsStoodInFor, + onOpen: onAttachOpen, + onPlusFrame: { model.overKeyboard.plusFrame = $0 } + ) + } + Spacer(minLength: 0) + // The open menu covers this row; only `+` stays, for the menu to collapse back into. + sendButton + .animation(Self.sendButtonSpring) { + $0.opacity(model.attachPanel.isOpen ? 0 : 1) + } + } + + let row = ZStack(alignment: .bottomLeading) { + textField + .background(alignment: .leading) { + // The draft's width on one line, to tell when the inline field would wrap. + Text(composer.draft.isEmpty ? " " : composer.draft) + .font(.appTextMessage) + .lineLimit(1) + .fixedSize() + .hidden() + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + draftLineWidth = width + updateStacking() + } } + // The text snaps to its new layout and `textShift` carries it there on the bar's spring. + .padding(.leading, textLeadingPadding(stacked: textIsStacked)) + .padding(.trailing, textIsStacked ? 0 : inlineTrailingPadding) + .offset(x: textShift) + // Position only, so it can spring with the bar without re-laying the text. + .padding(.bottom, isStacked ? BarMetrics.accessorySize + BarMetrics.fieldVerticalPadding : 0) + controls + } + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + rowWidth = width + updateStacking() + } + .onChange(of: composer.draft) { updateStacking() } + // An empty field is small beside the space send will take; a tap anywhere right of `+`, + // out to the field's edges, focuses it. Behind the row, so `+` keeps its own taps. + .background { + if composer.draft.isEmpty { + Color.clear + .padding(.vertical, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) + .contentShape(Rectangle()) + .onTapGesture { isFocused = true } + } + } + + // The staged photos ride inside the field, above the row, so the field reads as one message. + let content = 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, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) + .padding(.top, BarMetrics.fieldVerticalPadding / 2) + .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) } - .animation(Self.sendButtonSpring, value: showsSubmit) + row } - return field - .padding(.leading, 14) - .padding(.trailing, 8) - .padding(.vertical, BarMetrics.fieldVerticalPadding) - // Glass *behind* the field, not wrapping it: wrapping an editable - // TextField in `glassEffect` reparents its text view into the glass - // platter and breaks the text-selection grabbers. - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + return content + .padding(BarMetrics.fieldPadding) + // The field's glass is drawn by the bar, behind the whole row, so `$` can split from it. + .composerRim(in: RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous)) // Focus is the single source of `isComposing` — the button morph and the // screen's interactive-dismiss gate both key off it. Losing focus // (keyboard swiped down) ends composing. @@ -700,6 +1242,45 @@ struct ConversationComposer: View { } } + /// The confirm button. The spring is scoped to it, not to the row it sits in. + private var sendButton: some View { + // The spring is scoped to the button, not to the row. On the row it took the field + // into the transaction as well, and `showsSubmit` falls on the same update that empties + // the draft — so the field's text update ran as an animated one against its text view, + // where it can be coalesced away. That leaves the sent text on screen with the binding + // already empty, and an unchanged binding never pushes it again. + // A ZStack, not a Group: a Group hands its modifiers to its children, so with the button + // gone the slot's frame and the callbacks below would attach to nothing. + ZStack { + if submitIsShown { + Button(action: submit) { + Image(systemName: submitSymbol) + .font(.default(size: 16, weight: .bold)) + .foregroundStyle(Color.textAction) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(Color.white, in: Circle()) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(composer.isEditing ? "Save" : "Send") + .accessibilityIdentifier("send-message-button") + // Pop from 60% + fade, so the opacity ramp actually reads + // (scaling from 0 hides the fade behind a tiny speck). + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + } + // The slot stays, so the text does not reflow as the button comes and goes. + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + // Its own transaction after the text update: an implicit animation keyed on `showsSubmit` + // rode the text field's update, which carries no animation, so the pop never played. + .onAppear { submitIsShown = showsSubmit } + .onChange(of: showsSubmit) { _, shows in + withAnimation(Self.sendButtonSpring) { submitIsShown = shows } + } + } + /// The hint names what the field will send. An edit arrives with the existing text already in /// the field, so its hint is never on screen and stays the new-message one. private var fieldPrompt: String { @@ -719,33 +1300,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. - 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. + // 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, - 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 @@ -762,6 +1351,27 @@ struct ConversationComposer: View { /// The way out of an edit: the bar's leading control while the field holds an existing message, /// standing where Send Cash stands the rest of the time. Field-sized and glass, so the swap reads /// as the same control changing job rather than a foreign button arriving. +/// The round `$` beside the field. Its glass is drawn by the bar, joined to the field's. +private struct ComposerCashButton: View { + + let symbol: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(symbol) + .font(.appTextXL) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .composerRim(in: Circle()) + .accessibilityLabel("Send Cash") + .accessibilityIdentifier("send-cash-button") + } +} + private struct CancelEditButton: View { let onCancel: () -> Void @@ -800,7 +1410,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 b406fd7e7..dcbc59cec 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,84 @@ struct ConversationScreen: View { onGateJoin: joinChat, isJoiningChat: isJoiningChat, mentions: mentions, - authorAvatars: authorAvatars + authorAvatars: authorAvatars, + acceptsMedia: acceptsMedia, + onCameraCapture: stageCapturedPhoto, + onPhotosAdded: stageAddedPhotos, + mintMediaURL: mintMediaURL, + mediaBlobDecrypt: mediaBlobDecrypt, + onMediaTap: openMediaViewer ) } + /// Whether the chat takes photos: any chat this device has a record of, encrypted or not. False + /// until the record loads, so the menu never offers a photo before the chat is known. + private var acceptsMedia: Bool { + ChatMediaGate.acceptsMedia(conversationID.flatMap(conversationController.conversation(withID:))) + } + + /// Uploads chat photos on behalf of the signed-in owner, encrypted for the chat when it encrypts. + private var mediaUploader: ChatMediaUploader { + let controller = conversationController + let conversationID = conversationID + var uploader = ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) + uploader.seal = { + guard let conversationID else { return nil } + return try await controller.photoSeal(for: conversationID) + } + return uploader + } + + /// 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. + /// Decrypts this chat's end-to-end encrypted photos with the key its messages open with. + private var mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? { + { [controller = conversationController, conversationID] in + guard let conversationID else { return nil } + return await controller.mediaBlobDecrypt(for: conversationID) + } + } + + 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. @@ -1096,6 +1171,11 @@ struct ConversationScreen: View { fiat.nativeAmount.formatted(), .cash(token: token(fiat), flagImageName: ChatItem.flagImageName(for: fiat)) ) + case .media(let attachments, let caption): + ( + ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), + .media(thumbnailBlobID: message.redacted ? nil : attachments.first?.blobID, sealed: attachments.first?.sealed) + ) case .deleted: (ChatQuote.deletedSnippet, .unavailable) case .widget(.shareProfile): @@ -1187,7 +1267,7 @@ struct ConversationScreen: View { case .cash(let fiat): Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint) router.push(.currencyInfo(fiat.mint)) - case .text, .deleted, .encrypted, .widget: + case .text, .deleted, .encrypted, .widget, .media: break } } 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/Conversation/PendingMediaStore.swift b/Flipcash/Core/Screens/Conversation/PendingMediaStore.swift new file mode 100644 index 000000000..d7fe787e8 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/PendingMediaStore.swift @@ -0,0 +1,143 @@ +// +// PendingMediaStore.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +private let logger = Logger(label: "flipcash.pending-media-store") + +/// The photo sends that have left the composer but not yet been confirmed, kept on disk so a send +/// survives iOS killing the app while it is backgrounded. +/// +/// A JSON manifest plus one JPEG per photo, owner-scoped in Application Support and outside +/// `FlipcashStore` for the reasons `ChatDraftStore` gives: that store is rebuilt from the server on +/// a version bump, and an unsent photo cannot be re-fetched. +@MainActor +final class PendingMediaStore { + + /// One photo send, as `ChatMediaSendPlan` assigned it. + struct Entry: Codable, Equatable, Sendable { + var clientMessageID: UUID + /// The raw bytes of the chat's `ConversationID`. + var conversationID: Data + var createdAt: Date + var fileName: String + var caption: String? + var replyTo: MessageID? + /// The photo once its bytes are stored; nil while the encoded JPEG is the only thing held. + var stored: UploadedPhoto? + + init(clientMessageID: UUID, conversationID: ConversationID, createdAt: Date, caption: String?, replyTo: MessageID?, stored: UploadedPhoto? = nil) { + self.clientMessageID = clientMessageID + self.conversationID = conversationID.data + self.createdAt = createdAt + self.fileName = "\(clientMessageID.uuidString).jpg" + self.caption = caption + self.replyTo = replyTo + self.stored = stored + } + + /// The chat the photo is being sent to. + var chatID: ConversationID { ConversationID(data: conversationID) } + } + + private struct File: Codable { + var version: Int + var entries: [Entry] + } + + private static let version = 1 + + private let manifestURL: URL + private let photosDirectory: URL + private var held: [Entry] + + /// Loads the owner's pending sends. A manifest that cannot be read is treated as empty. + init(directory: URL, owner: PublicKey) { + let prefix = "flipcash-\(owner.base58)-pending-media" + self.manifestURL = directory.appendingPathComponent("\(prefix).json") + self.photosDirectory = directory.appendingPathComponent(prefix, isDirectory: true) + guard + let data = try? Data(contentsOf: manifestURL), + let file = try? JSONDecoder().decode(File.self, from: data), + file.version == Self.version + else { + self.held = [] + return + } + self.held = file.entries + } + + /// Every send still held, oldest first. + var entries: [Entry] { held } + + /// Starts holding `entry`, replacing one with the same client id. + func add(_ entry: Entry) { + held.removeAll { $0.clientMessageID == entry.clientMessageID } + held.append(entry) + writeManifest() + } + + /// Writes the encoded JPEG for a held send, so the photo survives a kill from here on. + func writeImage(_ data: Data, for clientMessageID: UUID) { + guard let entry = held.first(where: { $0.clientMessageID == clientMessageID }) else { return } + do { + try FileManager.default.createDirectory(at: photosDirectory, withIntermediateDirectories: true) + try data.write(to: photosDirectory.appendingPathComponent(entry.fileName), options: .atomic) + } catch { + ErrorReporting.captureError(error, reason: "Failed to write pending chat photo") + } + } + + /// The JPEG held for `entry`, or nil when its file is gone. + func imageData(for entry: Entry) -> Data? { + try? Data(contentsOf: photosDirectory.appendingPathComponent(entry.fileName)) + } + + /// Records that the send's bytes are stored, so a relaunch resumes instead of uploading again. + func setStored(_ photo: UploadedPhoto, for clientMessageID: UUID) { + guard let index = held.firstIndex(where: { $0.clientMessageID == clientMessageID }) else { return } + held[index].stored = photo + writeManifest() + } + + /// Forgets a send and deletes its photo — confirmed, discarded, or refused for good. + func remove(clientMessageID: UUID) { + guard let index = held.firstIndex(where: { $0.clientMessageID == clientMessageID }) else { return } + let entry = held.remove(at: index) + try? FileManager.default.removeItem(at: photosDirectory.appendingPathComponent(entry.fileName)) + writeManifest() + } + + /// Drops entries whose photo file is missing and deletes photo files no entry refers to. + func sweepOrphans() { + let before = held.count + held.removeAll { imageData(for: $0) == nil } + if held.count != before { writeManifest() } + + let known = Set(held.map(\.fileName)) + let files = (try? FileManager.default.contentsOfDirectory(atPath: photosDirectory.path)) ?? [] + for name in files where !known.contains(name) { + try? FileManager.default.removeItem(at: photosDirectory.appendingPathComponent(name)) + } + if before != held.count || files.count != known.count { + logger.info("Swept orphaned pending photos", metadata: [ + "droppedEntries": "\(before - held.count)", + "files": "\(files.count)", + ]) + } + } + + private func writeManifest() { + do { + let data = try JSONEncoder().encode(File(version: Self.version, entries: held)) + try data.write(to: manifestURL, options: .atomic) + } catch { + ErrorReporting.captureError(error, reason: "Failed to write pending chat photo manifest") + } + } +} 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/Screens/Main/Profile/DialogItem+ProfilePicture.swift b/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift index a63d3c362..026a649d8 100644 --- a/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift +++ b/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift @@ -58,7 +58,7 @@ extension DialogItem { // Privacy metadata is stripped from every upload, so a rejection for it // is a defect on our side rather than something a different photo fixes. - case .rejected(.privacyMetadata), .rejected(.unknown), .timedOut, + case .rejected(.privacyMetadata), .rejected(.internal), .rejected(.unknown), .rejected(.unrecognized), .timedOut, .uploadFailed, .notFound, .notUploaded, .unknown, .network: .error( title: "Couldn't Upload Your Photo", diff --git a/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift b/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift index 2a53f1b13..00173bc6a 100644 --- a/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift +++ b/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift @@ -117,10 +117,13 @@ nonisolated protocol BackgroundTaskAsserting: Sendable { /// ``BackgroundTaskAsserting`` over `UIApplication.shared`, hopping to the main thread it requires. nonisolated struct UIApplicationBackgroundTasks: BackgroundTaskAsserting { + /// The name iOS reports the task under in its diagnostics. + var name = "database.write" + func begin(expiration: @escaping @Sendable () -> Void) -> UIBackgroundTaskIdentifier { let begin = { MainActor.assumeIsolated { - UIApplication.shared.beginBackgroundTask(withName: "database.write", expirationHandler: expiration) + UIApplication.shared.beginBackgroundTask(withName: name, expirationHandler: expiration) } } return Thread.isMainThread ? begin() : DispatchQueue.main.sync(execute: begin) @@ -134,3 +137,35 @@ nonisolated struct UIApplicationBackgroundTasks: BackgroundTaskAsserting { } } } + +/// One background-task assertion, held from ``begin()`` until ``end()`` or until iOS expires it. +nonisolated final class BackgroundTimeAssertion: @unchecked Sendable { + + private let assertions: any BackgroundTaskAsserting + + /// Guarded by `lock`. + private let lock = NSLock() + private var taskID: UIBackgroundTaskIdentifier = .invalid + + init(assertions: any BackgroundTaskAsserting) { + self.assertions = assertions + } + + /// Asks iOS to keep the app running after it leaves the screen; a no-op when none is granted. + func begin() { + // Expiry only ends the assertion: the work under it carries on until iOS suspends the app. + let acquired = assertions.begin { [weak self] in self?.end() } + lock.withLock { taskID = acquired } + } + + /// Releases the assertion; safe to call more than once. + func end() { + let held = lock.withLock { + defer { taskID = .invalid } + return taskID + } + if held != .invalid { + assertions.end(held) + } + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index b637b4ea2..1d39d6a57 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -701,6 +701,17 @@ final class SessionContainer { // puts its text back — none of which the controller can do before it has the store. conversationController.chatDrafts = chatDrafts conversationController.chatArchive = chatArchive + // Same owner-scoped placement as the drafts; a photo whose send a kill interrupted comes back + // through `start()`, which needs the uploader to finish it. + conversationController.pendingMedia = PendingMediaStore( + directory: .applicationSupportDirectory, + owner: owner.publicKey + ) + conversationController.restoredPhotoUploader = { [weak conversationController, session, flipClient] conversationID in + var uploader = ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: flipClient)) + uploader.seal = { try await conversationController?.photoSeal(for: conversationID) } + return uploader + } let recentReactions = RecentReactionsStore(owner: owner.publicKey) self.recentReactions = recentReactions conversationController.reactions.recents = recentReactions diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 2c9f080cf..1167e363e 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -60,6 +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): ChatMediaStrings.listPreview(caption: caption) case .deleted, .encrypted, .widget(.unrecognized), nil: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift index f84e13418..9ad6431a7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift +++ b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift @@ -9,7 +9,8 @@ private let logger = Logger(label: "flipcash.blob-uploader") /// The blob RPCs an upload depends on. protocol BlobReserving: Sendable { - func initiateExternalUpload(mimeType: String, sizeBytes: Int, owner: KeyPair) async throws -> ReservedUpload + /// Reserves an upload of `sizeBytes`, end-to-end encrypted for the DM `encryptedFor` when set. + func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload func completeExternalUpload(blobID: BlobID, owner: KeyPair) async throws -> BlobState func blobState(blobID: BlobID, owner: KeyPair) async throws -> BlobState } @@ -40,7 +41,14 @@ final class BlobUploader: Sendable { /// /// Separate from `awaitFinalization` so a caller whose poll times out can /// resume the same blob instead of uploading a second copy. - func store(_ data: Data, mimeType: String, owner: KeyPair) async throws -> BlobID { + /// + /// `onProgress` hears the bytes going out to storage, from any thread. + func store( + _ data: Data, + mimeType: String, + owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in } + ) async throws -> BlobID { // Sanitize before reserving: the reservation signs the byte count, and // storage refuses an image carrying personal metadata. Doing it here // rather than at the call site is what makes it true of every upload. @@ -49,6 +57,7 @@ final class BlobUploader: Sendable { let reserved = try await reserving.initiateExternalUpload( mimeType: mimeType, sizeBytes: data.count, + encryptedFor: nil, owner: owner ) @@ -58,39 +67,111 @@ final class BlobUploader: Sendable { "sizeBytes": "\(data.count)", ]) - try await store(data, mimeType: mimeType, to: reserved.target) + try await store(data, mimeType: mimeType, to: reserved.target, onProgress: onProgress) switch try await reserving.completeExternalUpload(blobID: reserved.blobID, owner: owner) { case .rejected(let reason): + logger.info("Blob rejected on completion", metadata: [ + "blobId": "\(reserved.blobID)", + "reason": "\(reason)", + ]) throw ErrorBlob.rejected(reason) case .ready, .pending, .processing: return reserved.blobID } } + /// Stores `image` encrypted for the DM `conversationID` and returns its blob, leaving + /// finalization to the caller. + /// + /// `encrypt` seals the plaintext under the blob id the reservation assigns, which is part of + /// its aad, so the reservation declares the sealed size before the blob exists. `onProgress` + /// hears the sealed bytes going out to storage, from any thread. + func storeEncrypted( + _ image: Data, + for conversationID: ConversationID, + owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in }, + encrypt: @Sendable (Data, BlobID) throws -> Data + ) async throws -> EncryptedBlobUpload { + // The server never reads these bytes, so stripping location and camera metadata is on us. + let plaintext = JPEGMetadata.stripped(image) + let sizeBytes = plaintext.count + EncryptedBlobUpload.overhead + + let reserved = try await reserving.initiateExternalUpload( + mimeType: Self.encryptedMimeType, + sizeBytes: sizeBytes, + encryptedFor: conversationID, + owner: owner + ) + + logger.info("Reserved encrypted blob upload", metadata: [ + "blobId": "\(reserved.blobID)", + "sizeBytes": "\(sizeBytes)", + ]) + + let blob = try encrypt(plaintext, reserved.blobID) + guard blob.count == sizeBytes else { + logger.error("Encrypted blob is not the size reserved", metadata: [ + "blobId": "\(reserved.blobID)", + "reserved": "\(sizeBytes)", + "actual": "\(blob.count)", + ]) + throw ErrorBlob.unknown + } + + try await store(blob, mimeType: Self.encryptedMimeType, to: reserved.target, onProgress: onProgress) + + switch try await reserving.completeExternalUpload(blobID: reserved.blobID, owner: owner) { + case .rejected(let reason): + logger.info("Blob rejected on completion", metadata: [ + "blobId": "\(reserved.blobID)", + "reason": "\(reason)", + ]) + throw ErrorBlob.rejected(reason) + case .ready, .pending, .processing: + return EncryptedBlobUpload(blobID: reserved.blobID, plaintextSize: plaintext.count) + } + } + + /// The MIME type every end-to-end encrypted upload declares; the server refuses any other. + static let encryptedMimeType = "application/octet-stream" + /// Polls until the blob is finalized. /// - /// Returns immediately when it is already ready; a rejection is terminal. + /// Returns immediately when it is already ready; a rejection is terminal. A poll lost in transit + /// is retried, since iOS drops the connection when it suspends the app mid-wait. The `timeout` + /// budget is counted in polls rather than wall time, so time spent suspended doesn't use it up. func awaitFinalization(blobID: BlobID, owner: KeyPair) async throws { - let deadline = ContinuousClock.now.advanced(by: timeout) + let maxPolls = max(1, Int((timeout / pollInterval).rounded(.up))) + var polls = 0 while true { try Task.checkCancellation() + polls += 1 - switch try await reserving.blobState(blobID: blobID, owner: owner) { - case .ready: - return - case .rejected(let reason): - logger.info("Blob rejected", metadata: [ + do { + switch try await reserving.blobState(blobID: blobID, owner: owner) { + case .ready: + return + case .rejected(let reason): + logger.info("Blob rejected", metadata: [ + "blobId": "\(blobID)", + "reason": "\(reason)", + ]) + throw ErrorBlob.rejected(reason) + case .pending, .processing: + break + } + } catch ErrorBlob.network(let error) { + try Task.checkCancellation() + logger.info("Blob poll lost in transit", metadata: [ "blobId": "\(blobID)", - "reason": "\(reason)", + "error": "\(error)", ]) - throw ErrorBlob.rejected(reason) - case .pending, .processing: - break } - guard ContinuousClock.now < deadline else { + guard polls < maxPolls else { logger.info("Blob finalization timed out", metadata: ["blobId": "\(blobID)"]) throw ErrorBlob.timedOut } @@ -101,7 +182,12 @@ final class BlobUploader: Sendable { // MARK: - Upload - - private func store(_ data: Data, mimeType: String, to target: UploadTarget) async throws { + private func store( + _ data: Data, + mimeType: String, + to target: UploadTarget, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void + ) async throws { let boundary = "Boundary-\(UUID().uuidString)" let status: Int @@ -120,7 +206,8 @@ final class BlobUploader: Sendable { file: data, mimeType: mimeType, boundary: boundary - ) + ), + onProgress: onProgress ) } catch is CancellationError { throw CancellationError() @@ -163,6 +250,18 @@ final class BlobUploader: Sendable { } } +/// An encrypted blob stored but not yet finalized, with the length of the plaintext it seals. +public struct EncryptedBlobUpload: Hashable, Sendable { + /// The blob the reservation assigned. + public let blobID: BlobID + /// The plaintext image's length after metadata stripping, the length the recipient checks. + public let plaintextSize: Int + + /// What encryption adds to an image: the 24-byte nonce ahead of the ciphertext and the 16-byte + /// tag after it. + public static let overhead = 40 +} + private extension Data { mutating func append(_ string: String) { append(Data(string.utf8)) diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift index 1e5904562..c2e901673 100644 --- a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift +++ b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift @@ -14,10 +14,30 @@ protocol BlobUploading: Sendable { url: URL, contentType: String, headers: [String: String], - body: Data + body: Data, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void ) async throws -> (status: Int, body: Data) } +/// How much of an upload's request body has gone out. +public struct BlobUploadProgress: Sendable, Equatable { + /// Bytes of the request body sent so far. + public let sentBytes: Int64 + /// The request body's full length. + public let totalBytes: Int64 + + public init(sentBytes: Int64, totalBytes: Int64) { + self.sentBytes = sentBytes + self.totalBytes = totalBytes + } + + /// The share of the body sent, from 0 to 1, or nil when the length is unknown. + public var fraction: Double? { + guard totalBytes > 0 else { return nil } + return min(max(Double(sentBytes) / Double(totalBytes), 0), 1) + } +} + /// The production `BlobUploading`, over a shared `URLSession`. struct URLSessionBlobUploader: BlobUploading { @@ -31,7 +51,8 @@ struct URLSessionBlobUploader: BlobUploading { url: URL, contentType: String, headers: [String: String], - body: Data + body: Data, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void ) async throws -> (status: Int, body: Data) { var request = URLRequest(url: url) @@ -42,9 +63,33 @@ struct URLSessionBlobUploader: BlobUploading { request.setValue(value, forHTTPHeaderField: field) } - let (data, response) = try await session.upload(for: request, from: body) + // The body's own length, since `totalBytesExpectedToSend` can be unknown (-1). + let delegate = UploadProgressDelegate(totalBytes: Int64(body.count), onProgress: onProgress) + let (data, response) = try await session.upload(for: request, from: body, delegate: delegate) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 return (status, data) } } + +/// Forwards one upload task's sent-byte counts. +private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { + + private let totalBytes: Int64 + private let onProgress: @Sendable (BlobUploadProgress) -> Void + + init(totalBytes: Int64, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) { + self.totalBytes = totalBytes + self.onProgress = onProgress + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + onProgress(BlobUploadProgress(sentBytes: totalBytesSent, totalBytes: totalBytes)) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift new file mode 100644 index 000000000..68c2a8d4e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift @@ -0,0 +1,30 @@ +// +// ChatMediaConstraints.swift +// FlipcashCore +// + +import Foundation + +/// Which `UploadPolicy.mimeTypeConstraints` entry governs an upload — the first one, in policy +/// order, whose pattern matches. Pure string matching; the caller owns fetching the policy +/// (`BlobService.getUploadPolicy`). +public enum ChatMediaConstraints { + + /// Returns the index of the first pattern in `patterns` (already in policy order) that + /// matches `mimeType`, or nil if none does. A pattern is `"type/subtype"`, `"type/*"`, or + /// `"*/*"`. + public static func firstMatchIndex(patterns: [String], mimeType: String) -> Int? { + let mimeParts = mimeType.split(separator: "/", maxSplits: 1) + guard mimeParts.count == 2 else { return nil } + let (mimeType0, mimeType1) = (mimeParts[0], mimeParts[1]) + + return patterns.firstIndex { pattern in + if pattern == "*/*" { return true } + let patternParts = pattern.split(separator: "/", maxSplits: 1) + guard patternParts.count == 2 else { return false } + let (patternType, patternSubtype) = (patternParts[0], patternParts[1]) + guard patternType == mimeType0 else { return false } + return patternSubtype == "*" || patternSubtype == mimeType1 + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift new file mode 100644 index 000000000..a6a0372ab --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift @@ -0,0 +1,48 @@ +// +// ChatMediaDownscale.swift +// FlipcashCore +// + +import Foundation + +/// The single scale factor a chat photo is downscaled by before upload — pure math, no image +/// codec, so it can be pinned by `chat_media.json`'s `downscale` section without a UIKit +/// dependency. `ChatMediaEncoder` is what actually re-encodes at this target. +public enum ChatMediaDownscale { + + public struct Target: Equatable, Sendable { + public let width: Int + public let height: Int + } + + /// `maxWidth`/`maxHeight`/`maxPixels` of `0` mean unbounded on that axis. Never upscales — + /// the resulting edges are always `<= source`. + public static func target(sourceWidth w: Int, sourceHeight h: Int, maxWidth: Int, maxHeight: Int, maxPixels: Int) -> Target { + guard w > 0, h > 0 else { return Target(width: max(w, 1), height: max(h, 1)) } + + var scale = 1.0 + if maxWidth > 0 { scale = min(scale, Double(maxWidth) / Double(w)) } + if maxHeight > 0 { scale = min(scale, Double(maxHeight) / Double(h)) } + if maxPixels > 0 { + let pixelScale = (Double(maxPixels) / (Double(w) * Double(h))).squareRoot() + scale = min(scale, pixelScale) + } + + var targetWidth = max(Int(Double(w) * scale), 1) + var targetHeight = max(Int(Double(h) * scale), 1) + + // Flooring both edges independently can still leave the product over maxPixels by a + // pixel or two; shave the longer edge until it fits rather than re-deriving scale. + if maxPixels > 0 { + while targetWidth * targetHeight > maxPixels, targetWidth > 1 || targetHeight > 1 { + if targetWidth >= targetHeight { + targetWidth -= 1 + } else { + targetHeight -= 1 + } + } + } + + return Target(width: targetWidth, height: targetHeight) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift new file mode 100644 index 000000000..3774c9124 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift @@ -0,0 +1,151 @@ +// +// ChatMediaEncoder.swift +// FlipcashCore +// + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Re-encodes a chat photo as an upright JPEG at its `ChatMediaDownscale` target, stepping +/// quality down the ladder until the bytes fit the policy's size ceiling. +/// +/// The fixture pins the ladder, not the encoded bytes — encoders differ across OS versions. +public struct ChatMediaEncoder: Sendable { + + public enum Error: Swift.Error, Equatable { + /// The image could not be drawn or encoded at all. + case encodingFailed + /// Every quality on the ladder produced more bytes than the ceiling. + case tooLarge + } + + /// The MIME type every encoded photo is uploaded as. + public static let mimeType = "image/jpeg" + + /// JPEG qualities tried in order, highest first. + public static let qualityLadder: [Double] = [0.9, 0.8, 0.7, 0.6] + + private let encodeJPEG: @Sendable (CGImage, Double) -> Data? + + /// Creates an encoder backed by ImageIO. + public init() { + self.init(encodeJPEG: Self.jpeg) + } + + init(encodeJPEG: @escaping @Sendable (CGImage, Double) -> Data?) { + self.encodeJPEG = encodeJPEG + } + + /// Returns `image` drawn upright at `target` and encoded at the highest ladder quality + /// whose stripped bytes fit `maxSizeBytes`. + /// + /// `orientation` is the source's display orientation; `target` is in display space, so + /// its edges are swapped relative to `image` for a sideways capture. + public func encode( + _ image: CGImage, + orientation: CGImagePropertyOrientation = .up, + target: ChatMediaDownscale.Target, + maxSizeBytes: Int + ) throws -> Data { + guard let upright = Self.render(image, orientation: orientation, target: target) else { + throw Error.encodingFailed + } + + var encodedAny = false + for quality in Self.qualityLadder { + guard let data = encodeJPEG(upright, quality) else { continue } + encodedAny = true + + // Measured after stripping, since the stripped bytes are what the reservation signs. + let stripped = JPEGMetadata.stripped(data) + if stripped.count <= maxSizeBytes { + return stripped + } + } + + throw encodedAny ? Error.tooLarge : Error.encodingFailed + } + + // MARK: - Drawing - + + /// Returns `image` redrawn at `target` with `orientation` baked into the pixels, on an + /// opaque white background since JPEG carries no alpha. + static func render(_ image: CGImage, orientation: CGImagePropertyOrientation, target: ChatMediaDownscale.Target) -> CGImage? { + let width = CGFloat(target.width) + let height = CGFloat(target.height) + + let colorSpace = image.colorSpace.flatMap { $0.model == .rgb ? $0 : nil } + ?? CGColorSpace(name: CGColorSpace.sRGB)! + + guard let context = CGContext( + data: nil, + width: target.width, + height: target.height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { + return nil + } + + context.setFillColor(CGColor(gray: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.interpolationQuality = .high + + var transform = CGAffineTransform.identity + var drawSize = CGSize(width: width, height: height) + + switch orientation { + case .up, .upMirrored: + break + case .down, .downMirrored: + transform = transform.translatedBy(x: width, y: height).rotated(by: .pi) + case .left, .leftMirrored: + transform = transform.translatedBy(x: width, y: 0).rotated(by: .pi / 2) + drawSize = CGSize(width: height, height: width) + case .right, .rightMirrored: + transform = transform.translatedBy(x: 0, y: height).rotated(by: -.pi / 2) + drawSize = CGSize(width: height, height: width) + } + + switch orientation { + case .upMirrored, .downMirrored, .leftMirrored, .rightMirrored: + transform = transform.translatedBy(x: drawSize.width, y: 0).scaledBy(x: -1, y: 1) + case .up, .down, .left, .right: + break + } + + context.concatenate(transform) + context.draw(image, in: CGRect(origin: .zero, size: drawSize)) + + return context.makeImage() + } + + // MARK: - Encoding - + + /// Encodes `image` as a JPEG at `quality`, carrying no source metadata. + static func jpeg(_ image: CGImage, quality: Double) -> Data? { + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + return nil + } + + CGImageDestinationAddImage(destination, image, [ + kCGImageDestinationLossyCompressionQuality: quality, + ] as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + return nil + } + + return output as Data + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/UploadPolicyCache.swift b/FlipcashCore/Sources/FlipcashCore/Blob/UploadPolicyCache.swift new file mode 100644 index 000000000..fff413347 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/UploadPolicyCache.swift @@ -0,0 +1,63 @@ +// +// UploadPolicyCache.swift +// FlipcashCore +// + +import Foundation + +/// Holds the last fetched `UploadPolicy` until its TTL lapses or the server +/// reports a different version. +/// +/// In memory only, so a cold launch fetches once. +actor UploadPolicyCache { + + private struct Entry { + let owner: PublicKey + let policy: UploadPolicy + let expiresAt: ContinuousClock.Instant? + } + + private let now: @Sendable () -> ContinuousClock.Instant + private var entry: Entry? + private var inFlight: (owner: PublicKey, task: Task)? + + init(now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }) { + self.now = now + } + + /// Returns the cached policy for `owner`, or the result of `fetch` when + /// there is none still valid. + /// + /// Concurrent callers share one fetch, and a failed fetch is not cached. + func policy(for owner: KeyPair, fetch: @escaping @Sendable () async throws -> UploadPolicy) async throws -> UploadPolicy { + let key = owner.publicKey + + if let entry, entry.owner == key, entry.expiresAt.map({ now() < $0 }) ?? true { + return entry.policy + } + + if let inFlight, inFlight.owner == key { + return try await inFlight.task.value + } + + let task = Task { try await fetch() } + inFlight = (key, task) + defer { + if inFlight?.owner == key { + inFlight = nil + } + } + + let policy = try await task.value + entry = Entry(owner: key, policy: policy, expiresAt: policy.ttl.map { now().advanced(by: $0) }) + return policy + } + + /// Drops the cached policy when `version` differs from it — the server + /// echoes the version in force on a policy-driven denial. + func observe(version: String) { + if let entry, entry.policy.version != version { + self.entry = nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift index 9b5474f82..8ccb1c3df 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift @@ -11,9 +11,32 @@ extension FlipClient { /// /// Pair with `awaitBlobFinalization(blobID:owner:)`: holding the blob from /// the moment the bytes land is what lets a timed-out wait resume rather - /// than upload a second copy. - public func storeBlob(_ data: Data, mimeType: String, owner: KeyPair) async throws -> BlobID { - try await blobUploader.store(data, mimeType: mimeType, owner: owner) + /// than upload a second copy. `onProgress` hears the bytes going out, from any thread. + public func storeBlob( + _ data: Data, + mimeType: String, + owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in } + ) async throws -> BlobID { + try await blobUploader.store(data, mimeType: mimeType, owner: owner, onProgress: onProgress) + } + + /// Stores `image` end-to-end encrypted with `seal` for its DM and returns the blob, before the + /// server has finalized it. Pair with `awaitBlobFinalization(blobID:owner:)` as for `storeBlob`. + public func storeEncryptedBlob( + _ image: Data, + seal: ChatSeal, + owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in } + ) async throws -> EncryptedBlobUpload { + try await blobUploader.storeEncrypted(image, for: seal.conversationID, owner: owner, onProgress: onProgress) { plaintext, blobID in + try seal.encryptBlob(plaintext, blobID: blobID) + } + } + + /// Returns the upload constraints in force for `owner`. + public func uploadPolicy(owner: KeyPair) async throws -> UploadPolicy { + try await blobService.uploadPolicy(owner: owner) } /// Returns a freshly minted download URL for a blob the caller owns, or — diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index ad8e5ee08..d35ca880e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -190,6 +190,23 @@ 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) } + } + } + + /// Sends the finalized encrypted `photo` sealed with `seal`, with `caption` under it, and returns + /// the server's copy (decrypted). + @discardableResult + public func sendSealedMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: SealedPhoto, caption: String?, repliedTo: MessageID?, seal: ChatSeal, clientMessageID: UUID) async throws -> ConversationMessage { + try await withCheckedThrowingContinuation { c in + chatMessagingService.sendSealedMediaMessage(owner: owner, conversationID: conversationID, photo: photo, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) { c.resume(with: $0) } + } + } + public func editMessage( owner: KeyPair, conversationID: ConversationID, diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift index f592f9985..9bba3e255 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift @@ -10,6 +10,7 @@ import GRPCCore final class BlobService: Sendable { private let service: Flipcash_Blob_V1_BlobStorage.Client + private let policyCache = UploadPolicyCache() init(client: GRPCClient) { self.service = Flipcash_Blob_V1_BlobStorage.Client(wrapping: client) @@ -20,11 +21,8 @@ final class BlobService: Sendable { extension BlobService: BlobReserving { - func initiateExternalUpload(mimeType: String, sizeBytes: Int, owner: KeyPair) async throws -> ReservedUpload { - var request = Flipcash_Blob_V1_InitiateExternalUploadRequest() - request.mimeType = mimeType - request.sizeBytes = UInt64(sizeBytes) - request.auth = owner.authFor(message: request) + func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload { + let request = Self.initiateRequest(mimeType: mimeType, sizeBytes: sizeBytes, encryptedFor: encryptedFor, owner: owner) do { let response = try await service.initiateExternalUpload(request, options: .unaryDefault) @@ -43,8 +41,10 @@ extension BlobService: BlobReserving { case .denied: throw ErrorBlob.uploadDenied case .unsupportedType: + await observePolicyVersion(of: response) throw ErrorBlob.unsupportedType case .tooLarge: + await observePolicyVersion(of: response) throw ErrorBlob.tooLarge case .quotaExceeded: throw ErrorBlob.quotaExceeded @@ -83,6 +83,51 @@ extension BlobService: BlobReserving { } } + /// Returns the upload policy in force for `owner`, from the cache while it + /// is still valid. + func uploadPolicy(owner: KeyPair) async throws -> UploadPolicy { + try await policyCache.policy(for: owner) { [service] in + var request = Flipcash_Blob_V1_GetUploadPolicyRequest() + request.auth = owner.authFor(message: request) + + do { + let response = try await service.getUploadPolicy(request, options: .unaryDefault) + + switch response.result { + case .ok: + return UploadPolicy(response.policy) + case .denied: + throw ErrorBlob.uploadDenied + case .UNRECOGNIZED: + throw ErrorBlob.unknown + } + } catch let error as ErrorBlob { + throw error + } catch { + throw ErrorBlob.network(error) + } + } + } + + /// A policy-driven denial echoes the version in force, which retires a + /// stale cached policy. + /// The signed reservation request, naming the DM the bytes are encrypted for when set. + static func initiateRequest(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) -> Flipcash_Blob_V1_InitiateExternalUploadRequest { + var request = Flipcash_Blob_V1_InitiateExternalUploadRequest() + request.mimeType = mimeType + request.sizeBytes = UInt64(sizeBytes) + if let encryptedFor { + request.chat = encryptedFor.proto + } + request.auth = owner.authFor(message: request) + return request + } + + private func observePolicyVersion(of response: Flipcash_Blob_V1_InitiateExternalUploadResponse) async { + guard response.hasPolicyVersion else { return } + await policyCache.observe(version: response.policyVersion.value) + } + /// Returns a freshly minted download URL for a blob the caller owns, or — /// with an access context — one it can read through that surface. /// @@ -185,12 +230,18 @@ public enum BlobAccessContext: Sendable { /// through it. case chatProfile(ConversationID) + /// Reading media shared into `conversationID` as a message. Authorized + /// while the caller is a member of the chat. + case chatMessage(ConversationID) + var proto: Flipcash_Blob_V1_AccessContext { switch self { case .userProfile(let userID): return .with { $0.userProfile = .with { $0.value = userID.data } } case .chatProfile(let conversationID): return .with { $0.chatProfile = conversationID.proto } + case .chatMessage(let conversationID): + return .with { $0.chat = conversationID.proto } } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 0436a3914..537292d17 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -168,10 +168,52 @@ 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) + } + + /// Sends `photo`, its caption and reply inside the sealed plaintext, as `EncryptedContent`. + func sendSealedMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: SealedPhoto, caption: String?, repliedTo: MessageID?, seal: ChatSeal, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { + let content: Flipcash_Messaging_V1_Content + do { + content = try seal.seal(photo: photo, caption: caption, repliedTo: repliedTo) + } catch { + logger.error("Failed to encrypt photo message") + completion(.failure(.encryptionFailed)) + return + } + send(content, owner: owner, conversationID: conversationID, seal: seal, 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/Blob.swift b/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift index 6faa1e93c..39dbcd649 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift @@ -25,7 +25,12 @@ public enum BlobRejectionReason: Sendable, Equatable { case tooLarge case corrupt case privacyMetadata + /// The server failed to process the blob. + case `internal` + /// The server sent no reason. case unknown + /// The server sent a reason this build doesn't know, by its raw proto value. + case unrecognized(Int) } /// A reserved upload: the blob it will become, and the request that stores its @@ -51,6 +56,63 @@ struct UploadTarget: Sendable, Equatable { } } +/// The upload constraints in force for a caller, fetched with `GetUploadPolicy`. +public struct UploadPolicy: Sendable, Equatable { + + /// The bounds one MIME-type pattern imposes on a matching upload. + public struct MimeConstraint: Sendable, Equatable { + public let pattern: String + public let maxSizeBytes: Int + public let image: ImageConstraints? + } + + /// Pixel bounds on an image upload; `0` on an axis means unbounded. + public struct ImageConstraints: Sendable, Equatable { + public let maxWidth: Int + public let maxHeight: Int + public let maxPixels: Int + } + + /// Opaque generation token, compared by equality only. + public let version: String + + /// How long the policy may be relied on, or nil when the server sent none. + public let ttl: Duration? + + /// Constraints ordered most specific first. + public let constraints: [MimeConstraint] + + /// The constraints on an end-to-end encrypted upload, or nil when the caller may not make one. + public let encrypted: EncryptedConstraints? + + /// The bounds on an end-to-end encrypted upload, which the server checks by size alone. + public struct EncryptedConstraints: Sendable, Equatable { + /// The ceiling on the whole encrypted blob, nonce and tag included. + public let maxSizeBytes: Int + /// Advisory bounds to downscale an image to before encrypting it, or nil when there are none. + public let image: ImageConstraints? + + public init(maxSizeBytes: Int, image: ImageConstraints?) { + self.maxSizeBytes = maxSizeBytes + self.image = image + } + } + + init(version: String, ttl: Duration?, constraints: [MimeConstraint], encrypted: EncryptedConstraints? = nil) { + self.version = version + self.ttl = ttl + self.constraints = constraints + self.encrypted = encrypted + } + + /// Returns the constraint governing `mimeType` — the first match in policy + /// order — or nil when the policy accepts no upload of that type. + public func constraint(for mimeType: String) -> MimeConstraint? { + ChatMediaConstraints.firstMatchIndex(patterns: constraints.map(\.pattern), mimeType: mimeType) + .map { constraints[$0] } + } +} + // MARK: - Proto - extension BlobState { @@ -79,8 +141,9 @@ extension BlobRejectionReason { case .tooLarge: self = .tooLarge case .corrupt: self = .corrupt case .privacyMetadata: self = .privacyMetadata - case .internal: self = .unknown - case .unknown, .UNRECOGNIZED: self = .unknown + case .internal: self = .internal + case .unknown: self = .unknown + case .UNRECOGNIZED(let value): self = .unrecognized(value) } } } @@ -98,3 +161,51 @@ extension UploadTarget { ) } } + +extension UploadPolicy { + init(_ proto: Flipcash_Blob_V1_UploadPolicy) { + self.init( + version: proto.version.value, + ttl: proto.hasTtl ? .seconds(proto.ttl.seconds) + .nanoseconds(proto.ttl.nanos) : nil, + constraints: proto.mimeTypeConstraints.map(MimeConstraint.init), + encrypted: proto.hasEncrypted ? EncryptedConstraints(proto.encrypted) : nil + ) + } +} + +extension UploadPolicy.EncryptedConstraints { + init(_ proto: Flipcash_Blob_V1_EncryptedConstraints) { + self.init( + maxSizeBytes: Int(clamping: proto.maxSizeBytes), + image: proto.hasImage ? UploadPolicy.ImageConstraints(proto.image) : nil + ) + } +} + +extension UploadPolicy.ImageConstraints { + init(_ proto: Flipcash_Blob_V1_ImageConstraints) { + self.init( + maxWidth: Int(proto.maxWidth), + maxHeight: Int(proto.maxHeight), + maxPixels: Int(clamping: proto.maxPixels) + ) + } +} + +extension UploadPolicy.MimeConstraint { + init(_ proto: Flipcash_Blob_V1_MimeTypeConstraints) { + let image: UploadPolicy.ImageConstraints? + switch proto.kind { + case .image(let bounds): + image = UploadPolicy.ImageConstraints(bounds) + case nil: + image = nil + } + + self.init( + pattern: proto.mimeTypePattern, + maxSizeBytes: Int(clamping: proto.maxSizeBytes), + image: image + ) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift new file mode 100644 index 000000000..7ddc36651 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift @@ -0,0 +1,33 @@ +// +// 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 { + + /// Drawn over an encrypted photo whose bytes failed to decrypt. Not in the shared fixture yet: + /// Android doesn't decrypt photos. + public static let undecryptable = "This photo can\u{2019}t be displayed" + + /// 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..f4d8b8cd6 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,37 @@ 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 + /// What decrypting the blob needs when it is end-to-end encrypted, nil for a plaintext blob. + public let sealed: SealedBlob? + + public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?, caption: String?, isRedacted: Bool, sealed: SealedBlob? = nil) { + self.blobID = blobID + self.width = width + self.height = height + self.blurhash = blurhash + self.caption = caption + self.isRedacted = isRedacted + self.sealed = sealed + } +} + /// 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 04c2bc5ba..c8180173d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -91,6 +91,18 @@ extension ChatItem { content = .shareProfile(LinkCard.User(profileOf: share.username)) case .widget(.unrecognized): content = .unavailable(.updateApp) + 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, + sealed: attachment?.sealed + )) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } @@ -101,7 +113,7 @@ extension ChatItem { let isEmojiOnly: Bool switch message.content { case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted, .widget: isEmojiOnly = false + case .cash, .deleted, .encrypted, .widget, .media: isEmojiOnly = false } items.append(.message(ChatMessage( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift index 50908edf1..b780f56dd 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift @@ -20,6 +20,10 @@ public struct ChatQuote: Hashable, Sendable, Codable { /// as a bare number — `token` is the mint's name ("Cash" for USDF) and `flagImageName` is /// the currency's asset name, `nil` when the currency has no flag. case cash(token: String, flagImageName: String?) + /// A photo. The snippet is its caption, or "Photo" when it has none; `thumbnailBlobID` is the + /// blob the panel draws beside it, `nil` when there is none to fetch; `sealed` is what + /// decrypting it needs when it is end-to-end encrypted. + case media(thumbnailBlobID: BlobID?, sealed: SealedBlob?) /// The original is not in the local database, or it has been deleted. The panel renders /// the placeholder copy and the row is not tappable. case unavailable diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift index 9a975cd71..6a164a8a4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -10,7 +10,8 @@ import FlipcashAPI import SharedCore /// One DM's end-to-end encryption: the chat key both members derive, bound to the chat and both -/// public keys. Encrypts outgoing text and decrypts incoming `EncryptedContent`. +/// public keys. Encrypts outgoing text and photos, and decrypts incoming `EncryptedContent` and the +/// photo blobs it references. /// /// The cipher is shared-core's `ChatCipher` (scheme `X25519_XCHACHA20POLY1305`), so both apps agree /// byte for byte. Stateless after construction. @@ -26,6 +27,9 @@ public struct ChatSeal: @unchecked Sendable { private let chatID: KotlinByteArray private let selfUserID: UserID + /// The chat this seal encrypts for. + public let conversationID: ConversationID + /// Derives the chat key for `owner` and `peerPublicKey` in `conversationID`. /// /// Throws when shared-core rejects the peer key (not a valid point, low-order). @@ -47,11 +51,22 @@ public struct ChatSeal: @unchecked Sendable { self.peerPublicKey = peer self.chatID = chatID self.selfUserID = selfUserID + self.conversationID = conversationID } /// Encrypts `text`, as a reply to `repliedTo` when set, into the content to send in its place. public func seal(text: String, repliedTo: MessageID?) throws -> Flipcash_Messaging_V1_Content { - let plaintext = Flipcash_Messaging_V1_Content.plaintext(text: text, repliedTo: repliedTo) + try seal(.plaintext(text: text, repliedTo: repliedTo)) + } + + /// Encrypts a message carrying `photo`, with `caption` under it and as a reply to `repliedTo` + /// when set, into the content to send in its place. `photo` must already be uploaded through + /// ``encryptBlob(_:blobID:)`` and READY. + public func seal(photo: SealedPhoto, caption: String?, repliedTo: MessageID?) throws -> Flipcash_Messaging_V1_Content { + try seal(.plaintext(media: photo.media, caption: caption, repliedTo: repliedTo)) + } + + private func seal(_ plaintext: Flipcash_Messaging_V1_Content) throws -> Flipcash_Messaging_V1_Content { let payload = try cipher.encrypt( content: SharedBytes.shared.byteArray(data: try plaintext.serializedData()), chatKey: chatKey, @@ -68,6 +83,46 @@ public struct ChatSeal: @unchecked Sendable { } } + /// The blob to upload for `image`, the plaintext bytes of a photo this user sends as `blobID`: + /// a fresh 24-byte nonce followed by the ciphertext and its tag. + public func encryptBlob(_ image: Data, blobID: BlobID) throws -> Data { + let bytes = SharedBytes.shared + let blob = try cipher.encryptBlob( + image: bytes.byteArray(data: image), + chatKey: chatKey, + senderPk: ownPublicKey, + recipientPk: peerPublicKey, + chatId: chatID, + blobId: bytes.byteArray(data: blobID.data) + ) + return bytes.data(bytes: blob) + } + + /// The plaintext image in `blob`, the downloaded bytes of `blobID` that `sealed` describes. + /// + /// Throws ``BlobOpenFailure`` when the blob fails to authenticate or its plaintext is not the + /// length the sender declared. + public func decryptBlob(_ blob: Data, blobID: BlobID, sealed: SealedBlob) throws -> Data { + let bytes = SharedBytes.shared + let isFromSelf = sealed.senderID == selfUserID + let image: KotlinByteArray + do { + image = try cipher.decryptBlob( + blob: bytes.byteArray(data: blob), + chatKey: chatKey, + senderPk: isFromSelf ? ownPublicKey : peerPublicKey, + recipientPk: isFromSelf ? peerPublicKey : ownPublicKey, + chatId: chatID, + blobId: bytes.byteArray(data: blobID.data) + ) + } catch { + throw BlobOpenFailure.authentication + } + let data = bytes.data(bytes: image) + guard data.count == sealed.plaintextSize else { throw BlobOpenFailure.length } + return data + } + /// `message` with its content decrypted, or carrying why it could not be. A message that isn't /// `.encrypted` comes back unchanged. public func open(_ message: ConversationMessage) -> ConversationMessage { @@ -89,15 +144,72 @@ public struct ChatSeal: @unchecked Sendable { return message.opened(.failure(.authentication)) } - // Authenticated, so anything this client can't read is a newer client's content. + // Authenticated, so anything this client can't read, or a photo whose metadata breaks the + // contract, is a newer or misbehaving client's content. guard let content = try? Flipcash_Messaging_V1_Content(serializedBytes: bytes.data(bytes: plaintext)), - let readable = content.readableText else { + let senderID = message.senderID, + let readable = content.readable(senderID: senderID) else { return message.opened(.failure(.unsupported)) } return message.opened(.success(readable)) } } +/// Why a photo blob could not be decrypted; either way the photo draws as unsupported. +public enum BlobOpenFailure: Error, Hashable, Sendable { + /// The blob failed to authenticate under the chat key and its aad. + case authentication + /// The decrypted image is not the length the sender declared. + case length + /// The decrypted bytes don't decode as an image. + case undecodable +} + +/// A photo uploaded encrypted for a chat, with the plaintext metadata its sealed message carries. +public struct SealedPhoto: Hashable, Sendable, Codable { + /// The encrypted blob, READY before the message is sent. + public let blobID: BlobID + /// The plaintext image's MIME type, always an `image/` type. + public let mimeType: String + /// The plaintext image's length in bytes. + public let sizeBytes: Int + /// Pixel width of the plaintext image. + public let width: Int + /// Pixel height of the plaintext image. + public let height: Int + /// The plaintext image's BlurHash, at most 64 characters. + public let blurhash: String + + public init(blobID: BlobID, mimeType: String, sizeBytes: Int, width: Int, height: Int, blurhash: String) { + self.blobID = blobID + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.width = width + self.height = height + self.blurhash = blurhash + } + + /// The single-ORIGINAL `Media` the sealed message carries, with the sender-set metadata the + /// server never sees and no download URL. + var media: Flipcash_Blob_V1_Media { + .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + $0.blob = .with { + $0.mimeType = mimeType + $0.sizeBytes = UInt64(sizeBytes) + $0.image = .with { + $0.width = UInt32(width) + $0.height = UInt32(height) + $0.blurhash = blurhash + } + } + }] + } + } +} + extension Flipcash_Messaging_V1_Content { /// The plaintext content for `text`, wrapped as a reply to `repliedTo` when set. @@ -112,15 +224,92 @@ extension Flipcash_Messaging_V1_Content { } } - /// The text and replied-to message of decrypted Text or Reply(Text) content; nil for any other. - fileprivate var readableText: (text: String, repliedTo: MessageID?)? { + /// The media content for `media`, with `caption` under it, wrapped as a reply to `repliedTo` + /// when set. A reply nests the media one level deeper on the wire. + static func plaintext(media: Flipcash_Blob_V1_Media, caption: String?, repliedTo: MessageID?) -> Self { + let body = Self.with { + $0.media = .with { + $0.items = [media] + if let caption { + $0.caption = .with { $0.text = caption } + } + } + } + guard let repliedTo else { return body } + return .with { + $0.reply = .with { + $0.repliedMessageID = repliedTo.proto + $0.content = [body] + } + } + } + + /// The content and replied-to message of decrypted Text, Media, or a Reply of either sent by + /// `senderID`; nil for any other type, or for media whose metadata breaks the contract. + fileprivate func readable(senderID: UserID) -> (content: ConversationMessage.Content, repliedTo: MessageID?)? { switch type { case .text(let text): - return (text.text, nil) + return (.text(text.text), nil) + case .media(let media): + return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, nil) } case .reply(let reply): - guard reply.content.count == 1, case .text(let text) = reply.content[0].type else { return nil } - return (text.text, MessageID(reply.repliedMessageID)) - case .cash, .media, .system, .widget, .deleted, .encrypted, nil: + guard reply.content.count == 1 else { return nil } + let repliedTo = MessageID(reply.repliedMessageID) + switch reply.content[0].type { + case .text(let text): + return (.text(text.text), repliedTo) + case .media(let media): + return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, repliedTo) } + case .cash, .system, .widget, .deleted, .encrypted, .reply, nil: + return nil + } + case .cash, .system, .widget, .deleted, .encrypted, nil: + return nil + } + } +} + +extension ConversationMessage.Content { + + /// The `.media` content of a decrypted `MediaContent` sent by `senderID`, or nil when it breaks + /// the encrypted-media contract: exactly one item with exactly one ORIGINAL rendition, a blob id, + /// and valid `BlobMetadata` describing an image. + /// + /// The dimensions are the sender's claim; the decoded image's own are authoritative once it loads. + init?(sealed proto: Flipcash_Messaging_V1_MediaContent, senderID: UserID) { + guard proto.items.count == 1, + proto.items[0].renditions.count == 1 else { return nil } + let rendition = proto.items[0].renditions[0] + guard rendition.role == .original, + rendition.hasBlobID, + rendition.blobID.value.count == 16, + rendition.hasBlob, + let image = Self.validImage(rendition.blob) else { return nil } + let caption = proto.caption.text + self = .media( + [MediaAttachment( + blobID: BlobID(data: rendition.blobID.value), + width: Int(image.width), + height: Int(image.height), + blurhash: image.blurhash.isEmpty ? nil : image.blurhash, + sealed: SealedBlob(senderID: senderID, plaintextSize: Int(rendition.blob.sizeBytes)) + )], + caption: caption.isEmpty ? nil : caption + ) + } + + /// `metadata`'s image description when it passes the `blob.v1.BlobMetadata` rules for an image. + private static func validImage(_ metadata: Flipcash_Blob_V1_BlobMetadata) -> Flipcash_Blob_V1_ImageMetadata? { + let mimeType = metadata.mimeType + guard (1...255).contains(mimeType.unicodeScalars.count), + mimeType.lowercased().hasPrefix("image/"), + metadata.sizeBytes >= 1, + metadata.sizeBytes <= UInt64(Int.max) else { return nil } + switch metadata.kind { + case .image(let image): + guard image.width >= 1, image.height >= 1, image.blurhash.unicodeScalars.count <= 64 else { return nil } + return image + case .encrypted, nil: return nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 30861a627..1bf6f51ab 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -51,10 +51,14 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// End-to-end-encrypted content not yet decrypted: either decryption failed (see /// ``ConversationMessage/decryptFailure``) or the peer's key hasn't been fetched yet. `scheme` /// is the wire `EncryptedContent.Scheme` raw value, kept as `Int` so this model doesn't depend - /// on the generated proto enum. A decrypted message carries `.text` instead. + /// on the generated proto enum. A decrypted message carries `.text` or `.media` instead. case encrypted(scheme: Int, nonce: Data, ciphertext: Data) /// A widget: structured content the sender's client drew as a card rather than text. case widget(Widget) + /// Photos with an optional caption. A wire message carries exactly one attachment; a + /// redacted copy keeps its dimensions and blurhash and stays `.media`, with + /// ``ConversationMessage/redacted`` saying its bytes may not be fetched. + case media([MediaAttachment], caption: String?) } /// The widget a `.widget` message carries. Sent in the clear only: the E2EE allow-list is text, @@ -104,7 +108,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// When the sender last edited this message, or `nil` if it has never been edited. public let lastEditedTs: Date? /// The message this one replies to, or `nil` when it replies to nothing. A reply is a - /// decoration on a text message rather than a content kind of its own: the wire nests the + /// decoration on a text or media message rather than a content kind of its own: the wire nests the /// body inside `ReplyContent`, and the initializer below unwraps it so every `case .text` /// path — link detection, the transcript mapper, the bubble, edit — sees the shape it /// always saw. @@ -216,15 +220,15 @@ extension ConversationMessage { if case .encrypted = content { decryptFailure == nil } else { false } } - /// A copy carrying the outcome of decrypting it: the plaintext and the message it replies to, - /// or the reason it failed. Everything else is kept, including the ciphertext. - public func opened(_ outcome: Result<(text: String, repliedTo: MessageID?), DecryptFailure>) -> ConversationMessage { + /// A copy carrying the outcome of decrypting it: the decrypted content and the message it + /// replies to, or the reason it failed. Everything else is kept, including the ciphertext. + public func opened(_ outcome: Result<(content: Content, repliedTo: MessageID?), DecryptFailure>) -> ConversationMessage { let content: Content let repliedTo: MessageID? let failure: DecryptFailure? switch outcome { case .success(let plaintext): - content = .text(plaintext.text) + content = plaintext.content repliedTo = plaintext.repliedTo failure = nil case .failure(let reason): @@ -290,17 +294,27 @@ extension ConversationMessage { self.cashAction = nil repliedTo = nil case .reply(let replyContent): - // The wire nests the body one level down; unwrap it so the message is a text message - // that happens to point at another, not a second shape every `case .text` must learn. + // The wire nests the body one level down; unwrap it so the message is a text or media + // message that happens to point at another, not a second shape every case must learn. // `content` is repeated on the wire but carries exactly one entry in practice — a // reply with nothing inside has no body to draw, so it is dropped like any other // content the client cannot represent. - guard case .text(let textContent)? = replyContent.content.first?.type else { + switch replyContent.content.first?.type { + case .text(let textContent): + self.content = .text(textContent.text) + case .media(let mediaContent): + guard let content = Content(mediaContent) else { return nil } + self.content = content + case .cash, .deleted, .reply, .encrypted, .system, .widget, .none: return nil } - self.content = .text(textContent.text) self.cashAction = nil repliedTo = replyContent.hasRepliedMessageID ? MessageID(replyContent.repliedMessageID) : nil + case .media(let mediaContent): + guard let content = Content(mediaContent) else { return nil } + self.content = content + self.cashAction = nil + repliedTo = nil case .encrypted(let encryptedContent): // Kept undecrypted here: decryption needs the chat's keys, which `ChatSeal.open` applies. self.content = .encrypted( @@ -316,8 +330,8 @@ extension ConversationMessage { self.content = .widget(widgetContent.shareProfile.map(Widget.shareProfile) ?? .unrecognized) self.cashAction = nil repliedTo = nil - // `.media`/`.system` are dropped by design: the message is not stored and not shown. - case .media, .system, .none: + // `.system` is dropped by design: the message is not stored and not shown. + case .system, .none: return nil } @@ -366,8 +380,31 @@ extension ConversationMessage.Content { $0.ciphertext = ciphertext }) } + case .media(let attachments, let caption): + // Only a single uploaded attachment has a wire shape; staged or still-uploading + // attachments are fanned out and resolved before a send reaches this call. + guard attachments.count == 1, let media = attachments[0].proto else { + throw ConversationMessageContentEncodingError.unsupported(self) + } + return .with { + $0.type = .media(.with { + $0.items = [media] + if let caption { + $0.caption = .with { $0.text = caption } + } + }) + } case .cash, .deleted, .widget: throw ConversationMessageContentEncodingError.unsupported(self) } } + + /// The `.media` content `proto` describes, or `nil` when its first item has no ORIGINAL rendition. + init?(_ proto: Flipcash_Messaging_V1_MediaContent) { + guard let attachment = proto.items.first.flatMap(MediaAttachment.init) else { + return nil + } + let caption = proto.caption.text + self = .media([attachment], caption: caption.isEmpty ? nil : caption) + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift new file mode 100644 index 000000000..1c72a3bec --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift @@ -0,0 +1,86 @@ +// +// MediaAttachment.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// One photo attached to a chat message: the ORIGINAL rendition's blob and the intrinsic metadata +/// needed to lay it out before its bytes arrive. +public struct MediaAttachment: Hashable, Sendable, Codable { + + /// The ORIGINAL rendition's blob; `nil` only for an optimistic row whose upload has not + /// produced one yet. + public let blobID: BlobID? + + /// Pixel width of the ORIGINAL. + public let width: Int + + /// Pixel height of the ORIGINAL. + public let height: Int + + /// The ORIGINAL's BlurHash preview, or `nil` when the server carried none. + public let blurhash: String? + + /// How the blob decrypts when it is end-to-end encrypted for the chat; nil for a plaintext blob. + public let sealed: SealedBlob? + + public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?, sealed: SealedBlob? = nil) { + self.blobID = blobID + self.width = width + self.height = height + self.blurhash = blurhash + self.sealed = sealed + } +} + +/// What an end-to-end encrypted photo's blob needs, besides the chat key, to be decrypted and +/// checked: who uploaded it, which orders the public keys in its aad, and the plaintext length the +/// decrypted bytes must match. +public struct SealedBlob: Hashable, Sendable, Codable { + /// The member who uploaded the blob, always the sender of the message that references it. + public let senderID: UserID + /// The plaintext image's length in bytes, from the sender's sealed metadata. + public let plaintextSize: Int + + public init(senderID: UserID, plaintextSize: Int) { + self.senderID = senderID + self.plaintextSize = plaintextSize + } +} + +// MARK: - Proto - + +extension MediaAttachment { + + /// Returns the attachment described by `proto`'s ORIGINAL rendition, or `nil` when it carries none. + /// + /// A redacted copy still maps: the server keeps its dimensions and blurhash and drops only the + /// download URL, which this type never carries. + init?(_ proto: Flipcash_Blob_V1_Media) { + guard let original = proto.renditions.first(where: { $0.role == .original }) else { + return nil + } + let image = original.blob.image + self.init( + blobID: original.hasBlobID ? BlobID(data: original.blobID.value) : nil, + width: Int(image.width), + height: Int(image.height), + blurhash: image.blurhash.isEmpty ? nil : image.blurhash + ) + } + + /// The single-ORIGINAL `Media` a client attaches on send; `nil` when there is no blob to attach. + var proto: Flipcash_Blob_V1_Media? { + guard let blobID else { return nil } + return .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + }] + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 65116c33c..bfb97ea25 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -53,6 +53,7 @@ extension MessageCapability { /// |---|---| /// | Another participant's text | yes | /// | Another participant's cash | yes | + /// | Another participant's photo | yes | /// | Own message, confirmed | no | /// | Own message, unconfirmed | no | /// | A tombstone | no | @@ -93,6 +94,8 @@ extension MessageCapability { policy: MessagePolicy, now: Date ) -> Set { + // Media carries no text, so it offers neither copy nor edit — matching Android's resolver. + let hasText: Bool switch message.content { case .deleted, .encrypted: // Nothing is left to act on: a tombstone must not be re-deleted, and this client has no @@ -113,11 +116,13 @@ extension MessageCapability { // chat's speaker rule like any message. Sharing is the card's own button, not a menu action. return [.reply] case .text: - break + hasText = true + case .media: + hasText = false } guard message.isFromSelf(selfUserID) else { - return [.copy, .reply, .report] + return hasText ? [.copy, .reply, .report] : [.reply, .report] } // An unconfirmed message has no `eventSequence` to send as `expected_event_sequence`, so no @@ -127,9 +132,12 @@ extension MessageCapability { return [] } - var capabilities: Set = [.copy, .reply] - if isWithin(policy.editWindow, of: message, at: now) { - capabilities.insert(.edit) + var capabilities: Set = [.reply] + if hasText { + capabilities.insert(.copy) + if isWithin(policy.editWindow, of: message, at: now) { + capabilities.insert(.edit) + } } if isWithin(policy.deleteWindow, of: message, at: now) { capabilities.insert(.delete) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 6d8562487..17d2ae10a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -94,7 +94,7 @@ public enum NotificationPayload { switch message.content { case .text(let text): return text - case .cash, .deleted, .encrypted, .widget: + case .cash, .deleted, .encrypted, .widget, .media: return nil } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 28af2ab91..9b2b47e14 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -550,6 +550,7 @@ nonisolated extension Database { var encryptedScheme: Int? var encryptedNonce: Data? var encryptedCiphertext: Data? + var mediaJson: Data? if let sealed = message.sealed { encryptedScheme = sealed.scheme encryptedNonce = sealed.nonce @@ -582,6 +583,11 @@ nonisolated extension Database { case .shareProfile(let share): text = share.username.value case .unrecognized: text = nil } + case .media(let attachments, let caption): + kind = 5 + mediaJson = try JSONEncoder().encode( + StoredMedia(attachments: attachments, caption: caption, redacted: message.redacted) + ) } let cashAction: Int? = switch message.cashAction { @@ -615,7 +621,8 @@ nonisolated extension Database { m.encryptedNonce <- encryptedNonce, m.encryptedCiphertext <- encryptedCiphertext, m.decryptFailure <- message.decryptFailure?.rawValue, - m.reactionsJson <- Self.encodeReactions(message.reactionState) + m.reactionsJson <- Self.encodeReactions(message.reactionState), + m.mediaJson <- mediaJson ) ) } @@ -690,6 +697,15 @@ nonisolated extension Database { data.flatMap { try? JSONDecoder().decode(ReactionState.self, from: $0) } } + /// The `mediaJson` payload of a `.media` row. `redacted` rides here rather than in a column of + /// its own because only media acts on it: a redacted photo must reload redacted, or its bytes + /// would be fetched after a relaunch. + nonisolated private struct StoredMedia: Codable { + let attachments: [MediaAttachment] + let caption: String? + let redacted: Bool + } + // MARK: - Decode - /// Returns nil unless both rendition columns are present — the pair is @@ -758,6 +774,7 @@ nonisolated extension Database { let date = Date(timeIntervalSinceReferenceDate: row[m.date]) let content: ConversationMessage.Content + var redacted = false switch row[m.kind] { case 0: guard let text = row[m.text] else { return nil } @@ -800,6 +817,13 @@ nonisolated extension Database { content = .widget( row[m.text].flatMap(Username.init).map { .shareProfile(ShareProfileWidget(username: $0)) } ?? .unrecognized ) + case 5: + guard let data = row[m.mediaJson], + let media = try? JSONDecoder().decode(StoredMedia.self, from: data) else { + return nil + } + content = .media(media.attachments, caption: media.caption) + redacted = media.redacted default: return nil } @@ -820,6 +844,7 @@ nonisolated extension Database { lastEditedTs: row[m.lastEditedTs].map(Date.init(timeIntervalSinceReferenceDate:)), repliedTo: row[m.repliedToId].map(MessageID.init(value:)), clientMessageID: row[m.clientMessageID], + redacted: redacted, reactionState: Self.decodeReactions(row[m.reactionsJson]), sealed: sealed, decryptFailure: row[m.decryptFailure].flatMap(ConversationMessage.DecryptFailure.init(rawValue:)) diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index af9ab48c8..d194b4d9e 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 45 + public static let schemaVersion = 47 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index f199c0a1c..deb3eadc0 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -372,6 +372,8 @@ nonisolated public struct ConversationMessageTable: Sendable { public let deletedAt = Expression ("deletedAt") // JSON-encoded `ReactionState` (its confirmed server state only); nil when none is known. public let reactionsJson = Expression ("reactionsJson") + // JSON-encoded `.media` payload (attachments, caption, redaction); nil for a non-media row. + public let mediaJson = Expression ("mediaJson") } @@ -648,6 +650,7 @@ nonisolated extension Database { t.column(conversationMessageTable.encryptedCiphertext) t.column(conversationMessageTable.decryptFailure) t.column(conversationMessageTable.reactionsJson) + t.column(conversationMessageTable.mediaJson) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BlobRejectionReasonTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BlobRejectionReasonTests.swift new file mode 100644 index 000000000..5068b45de --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/BlobRejectionReasonTests.swift @@ -0,0 +1,31 @@ +// +// BlobRejectionReasonTests.swift +// FlipcashCoreTests +// + +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Blob rejection reason") +struct BlobRejectionReasonTests { + + @Test("Keeps an internal rejection distinct from an unset reason") + func internal_isNotUnknown() { + #expect(BlobRejectionReason(.internal) == .internal) + #expect(BlobRejectionReason(.unknown) == .unknown) + } + + @Test("Keeps the raw value of a reason this build doesn't know") + func unrecognized_keepsRawValue() { + let reason = BlobRejectionReason(.UNRECOGNIZED(42)) + #expect(reason == .unrecognized(42)) + #expect("\(reason)" == "unrecognized(42)") + } + + @Test("A rejected status carries the mapped reason") + func rejectedState_carriesReason() { + let rejection = Flipcash_Blob_V1_RejectionMetadata.with { $0.reason = .internal } + #expect(BlobState(status: .rejected, rejection: rejection) == .rejected(.internal)) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift index 6fc90c087..a69223731 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift @@ -4,12 +4,126 @@ // import Foundation +import Synchronization import Testing @testable import FlipcashCore @Suite("Blob upload") struct BlobUploaderTests { + // MARK: - End-to-end encrypted - + + @Test("An encrypted upload reserves for its DM, as an opaque blob the size of the sealed bytes") + func encryptedUploadReservesForChat() async throws { + let transport = RecordingTransport() + let reserving = StubReserving(states: [.ready]) + let uploader = BlobUploader(reserving: reserving, transport: transport, pollInterval: .milliseconds(1), timeout: .seconds(5)) + let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + let sealed = Data(repeating: 0xCD, count: Self.fileBytes.count + EncryptedBlobUpload.overhead) + + let upload = try await uploader.storeEncrypted(Self.fileBytes, for: conversationID, owner: try Self.owner()) { plaintext, blobID in + #expect(blobID == StubReserving.blobID) + #expect(plaintext == Self.fileBytes) + return sealed + } + + #expect(upload == EncryptedBlobUpload(blobID: StubReserving.blobID, plaintextSize: Self.fileBytes.count)) + #expect(await reserving.encryptedFor == conversationID) + #expect(await reserving.declaredMimeType == "application/octet-stream") + #expect(await reserving.declaredSizeBytes == sealed.count) + let parsed = try await Self.parse(transport) + #expect(parsed.file == sealed) + } + + @Test("Metadata is stripped before encrypting, and the plaintext size is the stripped length") + func encryptedUploadStripsBeforeSealing() async throws { + let uploader = makeUploader(transport: RecordingTransport(), states: [.ready]) + let jpeg = Self.jpegCarryingComment() + let stripped = JPEGMetadata.stripped(jpeg) + #expect(stripped.count < jpeg.count) + + let upload = try await uploader.storeEncrypted(jpeg, for: ConversationID(data: Data(repeating: 7, count: 32)), owner: try Self.owner()) { plaintext, _ in + #expect(plaintext == stripped) + return Data(count: plaintext.count + EncryptedBlobUpload.overhead) + } + + #expect(upload.plaintextSize == stripped.count) + } + + @Test("A sealed blob of a size other than the one reserved is never uploaded") + func encryptedUploadRefusesWrongSize() async throws { + let transport = RecordingTransport() + let uploader = makeUploader(transport: transport, states: [.ready]) + + await #expect(throws: ErrorBlob.self) { + _ = try await uploader.storeEncrypted(Self.fileBytes, for: ConversationID(data: Data(repeating: 7, count: 32)), owner: try Self.owner()) { plaintext, _ in + plaintext + } + } + #expect(await transport.body == nil) + } + + // MARK: - Progress - + + @Test("Forwards the transport's sent-byte counts for a plaintext upload") + func store_forwardsProgress() async throws { + let uploader = makeUploader(transport: RecordingTransport(), states: [.ready]) + let seen = Mutex<[BlobUploadProgress]>([]) + + _ = try await uploader.store(Self.fileBytes, mimeType: "image/jpeg", owner: try Self.owner()) { progress in + seen.withLock { $0.append(progress) } + } + + let fractions = seen.withLock { $0 }.compactMap(\.fraction) + #expect(fractions.count == 2) + #expect(fractions.first.map { $0 > 0 && $0 < 1 } == true) + #expect(fractions.last == 1.0) + } + + @Test("Forwards the sealed bytes' progress for an encrypted upload") + func storeEncrypted_forwardsProgress() async throws { + let uploader = makeUploader(transport: RecordingTransport(), states: [.ready]) + let seen = Mutex<[BlobUploadProgress]>([]) + + _ = try await uploader.storeEncrypted( + Self.fileBytes, + for: ConversationID(data: Data(repeating: 7, count: 32)), + owner: try Self.owner(), + onProgress: { progress in seen.withLock { $0.append(progress) } } + ) { plaintext, _ in + Data(count: plaintext.count + EncryptedBlobUpload.overhead) + } + + #expect(seen.withLock { $0 }.last?.fraction == 1.0) + } + + @Test("Maps sent bytes to a fraction of the body, clamped to 0...1") + func progress_fraction() { + #expect(BlobUploadProgress(sentBytes: 0, totalBytes: 200).fraction == 0) + #expect(BlobUploadProgress(sentBytes: 50, totalBytes: 200).fraction == 0.25) + #expect(BlobUploadProgress(sentBytes: 200, totalBytes: 200).fraction == 1) + #expect(BlobUploadProgress(sentBytes: 300, totalBytes: 200).fraction == 1) + #expect(BlobUploadProgress(sentBytes: -1, totalBytes: 200).fraction == 0) + } + + @Test("Reports no fraction when the body's length is unknown") + func progress_unknownLength() { + #expect(BlobUploadProgress(sentBytes: 10, totalBytes: 0).fraction == nil) + #expect(BlobUploadProgress(sentBytes: 10, totalBytes: -1).fraction == nil) + } + + @Test("The reservation names the chat only for an encrypted upload") + func reservationRequestNamesChat() throws { + let owner = try Self.owner() + let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + + let encrypted = BlobService.initiateRequest(mimeType: "application/octet-stream", sizeBytes: 10, encryptedFor: conversationID, owner: owner) + let plain = BlobService.initiateRequest(mimeType: "image/jpeg", sizeBytes: 10, encryptedFor: nil, owner: owner) + + #expect(encrypted.endToEndEncryptedFor == .chat(conversationID.proto)) + #expect(plain.endToEndEncryptedFor == nil) + } + // MARK: - Multipart body - @Test("Signed policy fields come first, the file last") @@ -261,6 +375,44 @@ struct BlobUploaderTests { #expect(await reserving.reserveCount == 0) } + /// iOS drops the connection when it suspends the app mid-wait; one lost poll is not a failed + /// upload. + @Test("A poll lost in transit is retried rather than failing the upload") + func lostPollIsRetried() async throws { + let transport = RecordingTransport() + let reserving = StubReserving(states: [.processing, .ready], lostPolls: 2) + let uploader = BlobUploader( + reserving: reserving, + transport: transport, + pollInterval: .milliseconds(1), + timeout: .seconds(5) + ) + + try await uploader.awaitFinalization(blobID: StubReserving.blobID, owner: try Self.owner()) + + #expect(await reserving.pollCount == 4) + #expect(await transport.body == nil) + } + + @Test("Polls lost in transit still count toward the deadline") + func lostPollsHonourTheDeadline() async throws { + let reserving = StubReserving(states: [], lostPolls: .max) + let uploader = BlobUploader( + reserving: reserving, + transport: RecordingTransport(), + pollInterval: .milliseconds(1), + timeout: .milliseconds(5) + ) + + await #expect { + try await uploader.awaitFinalization(blobID: StubReserving.blobID, owner: try Self.owner()) + } throws: { error in + guard case ErrorBlob.timedOut = error else { return false } + return true + } + #expect(await reserving.pollCount == 5) + } + // MARK: - Fixtures - private static let fileBytes = Data(repeating: 0xAB, count: 4096) @@ -305,6 +457,7 @@ struct BlobUploaderTests { let names: [String] let values: [String: String] let fileByteCount: Int + let file: Data } private static func parse(_ transport: RecordingTransport) async throws -> ParsedBody { @@ -320,6 +473,7 @@ struct BlobUploaderTests { var names: [String] = [] var values: [String: String] = [:] var fileByteCount = 0 + var file = Data() for segment in Self.segments(of: body, delimitedBy: "--\(boundary)\r\n") { guard let terminator = segment.range(of: Data("\r\n\r\n".utf8)) else { continue } @@ -334,12 +488,13 @@ struct BlobUploaderTests { if name == "file" { fileByteCount = payload.count + file = payload } else { values[name] = String(decoding: payload, as: UTF8.self) } } - return ParsedBody(names: names, values: values, fileByteCount: fileByteCount) + return ParsedBody(names: names, values: values, fileByteCount: fileByteCount, file: file) } private static func segments(of body: Data, delimitedBy delimiter: String) -> [Data] { @@ -386,7 +541,18 @@ private actor RecordingTransport: BlobUploading { self.failure = error } - func post(url: URL, contentType: String, headers: [String: String], body: Data) async throws -> (status: Int, body: Data) { + func post( + url: URL, + contentType: String, + headers: [String: String], + body: Data, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void + ) async throws -> (status: Int, body: Data) { + // Reports the body going out in two halves, as URLSession does in chunks. + let total = Int64(body.count) + onProgress(BlobUploadProgress(sentBytes: total / 2, totalBytes: total)) + onProgress(BlobUploadProgress(sentBytes: total, totalBytes: total)) + self.url = url self.contentType = contentType self.headers = headers @@ -406,18 +572,25 @@ private actor StubReserving: BlobReserving { private var states: [BlobState] private let completion: BlobState + private var lostPolls: Int private(set) var pollCount = 0 private(set) var reserveCount = 0 private(set) var declaredSizeBytes: Int? + private(set) var declaredMimeType: String? + private(set) var encryptedFor: ConversationID? - init(states: [BlobState], completion: BlobState = .processing) { + /// `lostPolls` is how many polls fail in transit before the stub starts answering. + init(states: [BlobState], completion: BlobState = .processing, lostPolls: Int = 0) { self.states = states self.completion = completion + self.lostPolls = lostPolls } - func initiateExternalUpload(mimeType: String, sizeBytes: Int, owner: KeyPair) async throws -> ReservedUpload { + func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload { reserveCount += 1 declaredSizeBytes = sizeBytes + declaredMimeType = mimeType + self.encryptedFor = encryptedFor return ReservedUpload( blobID: Self.blobID, @@ -441,6 +614,10 @@ private actor StubReserving: BlobReserving { func blobState(blobID: BlobID, owner: KeyPair) async throws -> BlobState { pollCount += 1 + if lostPolls > 0 { + lostPolls -= 1 + throw ErrorBlob.network(URLError(.networkConnectionLost)) + } return states.isEmpty ? .processing : states.removeFirst() } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatMediaEncoderTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatMediaEncoderTests.swift new file mode 100644 index 000000000..2f643a74e --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatMediaEncoderTests.swift @@ -0,0 +1,171 @@ +// +// ChatMediaEncoderTests.swift +// FlipcashCoreTests +// + +import CoreGraphics +import Foundation +import ImageIO +import Synchronization +import Testing +@testable import FlipcashCore + +@Suite("Chat media encoder") +struct ChatMediaEncoderTests { + + // MARK: - Quality ladder - + + @Test("Walks the ladder highest first and stops at the first quality that fits") + func stopsAtFirstFit() throws { + let attempts = Mutex<[Double]>([]) + let encoder = ChatMediaEncoder { _, quality in + attempts.withLock { $0.append(quality) } + return Data(count: quality > 0.75 ? 200 : 100) + } + + let data = try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: 100) + + #expect(data.count == 100) + #expect(attempts.withLock { $0 } == [0.9, 0.8, 0.7]) + } + + @Test("Throws tooLarge when no quality on the ladder fits") + func givesUpAfterLastStep() throws { + let attempts = Mutex<[Double]>([]) + let encoder = ChatMediaEncoder { _, quality in + attempts.withLock { $0.append(quality) } + return Data(count: 200) + } + + #expect(throws: ChatMediaEncoder.Error.tooLarge) { + try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: 100) + } + #expect(attempts.withLock { $0 } == ChatMediaEncoder.qualityLadder) + } + + @Test("Throws encodingFailed when no quality encodes at all") + func encodeFailure() throws { + let encoder = ChatMediaEncoder { _, _ in nil } + + #expect(throws: ChatMediaEncoder.Error.encodingFailed) { + try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: 100) + } + } + + /// The reservation signs the stripped bytes, so a JPEG that fits only once + /// its EXIF is gone must be accepted — and handed back stripped. + @Test("Measures the size after stripping metadata") + func measuresStrippedSize() throws { + let exif = Data([0xFF, 0xE1, 0x00, 0x0A] + Array("Exif\0\0GPS".utf8).prefix(8)) + let bare = Data([0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0x11, 0xFF, 0xD9]) + let withExif = bare.prefix(2) + exif + bare.dropFirst(2) + let encoder = ChatMediaEncoder { _, _ in withExif } + + let data = try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: bare.count) + + #expect(data == bare) + } + + // MARK: - Real encode - + + @Test("Encodes a real JPEG at the target size") + func encodesAtTarget() throws { + let data = try ChatMediaEncoder().encode( + Self.image(width: 40, height: 20), + target: .init(width: 20, height: 10), + maxSizeBytes: 1_000_000 + ) + + let (width, height) = try Self.pixelSize(of: data) + #expect(width == 20) + #expect(height == 10) + #expect(data.prefix(2) == Data([0xFF, 0xD8])) + } + + /// A portrait capture arrives as landscape pixels tagged `.right`; the + /// target is in display space, and the output must decode upright without + /// an orientation tag. + @Test("Bakes a sideways orientation into upright pixels") + func bakesOrientation() throws { + let data = try ChatMediaEncoder().encode( + Self.image(width: 40, height: 20), + orientation: .right, + target: .init(width: 10, height: 20), + maxSizeBytes: 1_000_000 + ) + + let (width, height) = try Self.pixelSize(of: data) + #expect(width == 10) + #expect(height == 20) + + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + let orientation = properties?[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == nil || orientation == CGImagePropertyOrientation.up.rawValue) + } + + /// `.right` stores the displayed top along the pixels' left edge, so a + /// source red on its left half must display red on top. + @Test("Rotates a .right capture the way a viewer displays it") + func rotatesInDisplayDirection() throws { + let data = try ChatMediaEncoder().encode( + Self.image(width: 40, height: 20, leftHalf: CGColor(red: 1, green: 0, blue: 0, alpha: 1)), + orientation: .right, + target: .init(width: 10, height: 20), + maxSizeBytes: 1_000_000 + ) + + let top = try Self.pixel(of: data, x: 5, y: 2) + let bottom = try Self.pixel(of: data, x: 5, y: 17) + #expect(top.red > 200 && top.blue < 60) + #expect(bottom.blue > 200 && bottom.red < 60) + } + + // MARK: - Fixtures - + + private static func image(width: Int, height: Int, leftHalf: CGColor? = nil) throws -> CGImage { + let context = try #require(CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + if let leftHalf { + context.setFillColor(CGColor(red: 0, green: 0, blue: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.setFillColor(leftHalf) + context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height)) + } + return try #require(context.makeImage()) + } + + /// Returns the decoded RGB at `(x, y)`, with `y` counted from the top. + private static func pixel(of data: Data, x: Int, y: Int) throws -> (red: UInt8, green: UInt8, blue: UInt8) { + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + var buffer = [UInt8](repeating: 0, count: image.width * image.height * 4) + let context = try #require(CGContext( + data: &buffer, + width: image.width, + height: image.height, + bitsPerComponent: 8, + bytesPerRow: image.width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let offset = (y * image.width + x) * 4 + return (buffer[offset], buffer[offset + 1], buffer[offset + 2]) + } + + private static func pixelSize(of data: Data) throws -> (Int, Int) { + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + return (image.width, image.height) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift index bf2d53444..e0ec458c6 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift @@ -38,6 +38,25 @@ struct ChatPreviewDecryptionTests { #expect(contents(items) == [.text("hello")]) } + @Test("A decrypted photo previews as a photo that still knows it is encrypted") + func decryptedPhotoPreviewsAsPhoto() throws { + let blobID = BlobID(data: Data([7])) + let seal = SealedBlob(senderID: them, plaintextSize: 42) + let attachment = MediaAttachment(blobID: blobID, width: 4, height: 3, blurhash: "LKO2?U%2Tw=w", sealed: seal) + var message = encrypted(1, from: them) + message = message.opened(.success((content: .media([attachment], caption: "beach"), repliedTo: nil))) + + let items = ChatItem.preview(from: [message], selfUserID: me) + let content = try #require(contents(items).first) + guard case .media(let media) = content else { + Issue.record("Expected a photo, got \(content)") + return + } + #expect(media.blobID == blobID) + #expect(media.caption == "beach") + #expect(media.sealed == seal) + } + @Test("A message waiting on the peer's key is left out of the preview") func awaitingIsLeftOut() { let items = ChatItem.preview(from: [encrypted(1, from: them, text: "hi"), encrypted(2, from: them)], selfUserID: me) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift index 8f2256b5c..a95ec7e2a 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift @@ -127,4 +127,174 @@ struct ChatSealTests { #expect(encrypted.scheme == .x25519Xchacha20Poly1305) #expect(encrypted.nonce.count == 24) } + + // MARK: - Photos - + + private let blobID = BlobID(data: Data(repeating: 0xA5, count: 16)) + + private func photo(sizeBytes: Int = 1234) -> SealedPhoto { + SealedPhoto(blobID: blobID, mimeType: "image/jpeg", sizeBytes: sizeBytes, width: 640, height: 480, blurhash: "LEHV6nWB2yk8") + } + + /// The decrypted `.media` attachment `content` carries. + private func attachment(_ content: ConversationMessage.Content) throws -> (MediaAttachment, String?) { + guard case .media(let attachments, let caption) = content, attachments.count == 1 else { + Issue.record("Expected one media attachment, got \(content)") + throw CancellationError() + } + return (attachments[0], caption) + } + + @Test("The peer decrypts a sealed photo to its blob and sender-set metadata") + func photoRoundTrip() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(photo: photo(), caption: nil, repliedTo: nil) + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + let (media, caption) = try attachment(opened.content) + #expect(media == MediaAttachment( + blobID: blobID, width: 640, height: 480, blurhash: "LEHV6nWB2yk8", + sealed: SealedBlob(senderID: aliceID, plaintextSize: 1234) + )) + #expect(caption == nil) + #expect(opened.repliedTo == nil) + #expect(opened.decryptFailure == nil) + } + + @Test("A sealed photo carries its caption and the message it replies to inside the ciphertext") + func photoCaptionAndReply() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(photo: photo(), caption: "sunset", repliedTo: MessageID(value: 9)) + guard case .encrypted = sealed.type else { + Issue.record("Expected encrypted content") + return + } + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + let (_, caption) = try attachment(opened.content) + #expect(caption == "sunset") + #expect(opened.repliedTo == MessageID(value: 9)) + } + + @Test("The sender's other device opens its own sealed photo") + func photoOwnMessage() throws { + let aliceSeal = try seal(owner: alice, peer: bob, selfID: aliceID) + let opened = aliceSeal.open(try message(aliceSeal.seal(photo: photo(), caption: nil, repliedTo: nil), from: aliceID)) + + let (media, _) = try attachment(opened.content) + #expect(media.sealed?.senderID == aliceID) + } + + @Test("Both members decrypt the blob the sender encrypted") + func blobRoundTrip() throws { + let image = Data((0..<4096).map { UInt8(truncatingIfNeeded: $0) }) + let sealedBlob = SealedBlob(senderID: aliceID, plaintextSize: image.count) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + + #expect(blob.count == image.count + 24 + 16) + #expect(try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: blobID, sealed: sealedBlob) == image) + #expect(try seal(owner: alice, peer: bob, selfID: aliceID).decryptBlob(blob, blobID: blobID, sealed: sealedBlob) == image) + } + + @Test("A blob read under another blob id fails authentication") + func blobWrongID() throws { + let image = Data(repeating: 1, count: 100) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + let other = BlobID(data: Data(repeating: 0, count: 16)) + + #expect(throws: BlobOpenFailure.authentication) { + try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: other, sealed: SealedBlob(senderID: aliceID, plaintextSize: 100)) + } + } + + @Test("A blob attributed to the wrong sender fails authentication") + func blobSwappedKeys() throws { + let image = Data(repeating: 1, count: 100) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + + // Bob reading it as his own upload puts the public keys in the aad the wrong way round. + #expect(throws: BlobOpenFailure.authentication) { + try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: blobID, sealed: SealedBlob(senderID: bobID, plaintextSize: 100)) + } + } + + @Test("A blob whose plaintext is not the declared size is rejected") + func blobLengthMismatch() throws { + let image = Data(repeating: 1, count: 100) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + + #expect(throws: BlobOpenFailure.length) { + try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: blobID, sealed: SealedBlob(senderID: aliceID, plaintextSize: 99)) + } + } + + /// The opened result of `media`, sealed by Alice by hand so it can break the contract. + private func openHandSealed(_ media: Flipcash_Messaging_V1_MediaContent) throws -> ConversationMessage { + let bytes = SharedBytes.shared + let plaintext = Flipcash_Messaging_V1_Content.with { $0.media = media } + let owner = SharedCore.KeyPair(publicKey: bytes.byteArray(data: alice.publicKey.data), privateKey: bytes.byteArray(data: alice.privateKey.data)) + let chatKey = try DefaultChatCipher.shared.chatKey(ownKeyPair: owner, peerPublicKey: bytes.byteArray(data: bob.publicKey.data), chatId: bytes.byteArray(data: chatID.data)) + let payload = try DefaultChatCipher.shared.encrypt( + content: bytes.byteArray(data: try plaintext.serializedData()), + chatKey: chatKey, + senderPk: bytes.byteArray(data: alice.publicKey.data), + recipientPk: bytes.byteArray(data: bob.publicKey.data), + chatId: bytes.byteArray(data: chatID.data) + ) + let content = Flipcash_Messaging_V1_Content.with { + $0.encrypted = .with { + $0.scheme = ChatSeal.scheme + $0.nonce = bytes.data(bytes: payload.nonce) + $0.ciphertext = bytes.data(bytes: payload.ciphertext) + } + } + return try seal(owner: bob, peer: alice, selfID: bobID).open(message(content, from: aliceID)) + } + + /// A valid sealed photo's `MediaContent`, for the validation cases to break one field of. + private var validMedia: Flipcash_Messaging_V1_MediaContent { + .with { $0.items = [photo().media] } + } + + @Test("Hand-sealed valid media opens, so the cases below fail for their one broken field") + func handSealedValid() throws { + #expect(try openHandSealed(validMedia).decryptFailure == nil) + } + + @Test( + "Media whose metadata breaks the contract is unsupported", + arguments: [ + "zero size", "non-image mime", "empty mime", "long mime", "zero width", "zero height", + "long blurhash", "no metadata", "no blob id", "short blob id", "not original", + "two renditions", "two items", "no items", "no image metadata", + ] + ) + func invalidMediaIsUnsupported(_ breakage: String) throws { + var media = validMedia + switch breakage { + case "zero size": media.items[0].renditions[0].blob.sizeBytes = 0 + case "non-image mime": media.items[0].renditions[0].blob.mimeType = "video/mp4" + case "empty mime": media.items[0].renditions[0].blob.mimeType = "" + case "long mime": media.items[0].renditions[0].blob.mimeType = "image/" + String(repeating: "x", count: 250) + case "zero width": media.items[0].renditions[0].blob.image.width = 0 + case "zero height": media.items[0].renditions[0].blob.image.height = 0 + case "long blurhash": media.items[0].renditions[0].blob.image.blurhash = String(repeating: "L", count: 65) + case "no metadata": media.items[0].renditions[0].clearBlob() + case "no blob id": media.items[0].renditions[0].clearBlobID() + case "short blob id": media.items[0].renditions[0].blobID.value = Data([1, 2, 3]) + case "not original": media.items[0].renditions[0].role = .unknown + case "two renditions": media.items[0].renditions.append(media.items[0].renditions[0]) + case "two items": media.items.append(media.items[0]) + case "no items": media.items = [] + case "no image metadata": media.items[0].renditions[0].blob.kind = nil + default: Issue.record("Unknown breakage \(breakage)") + } + + #expect(try openHandSealed(media).decryptFailure == .unsupported) + } + + @Test("A download URL inside sealed media is ignored") + func downloadURLIgnored() throws { + var media = validMedia + media.items[0].renditions[0].blob.downloadURL = .with { $0.url = "https://example.com/x" } + #expect(try openHandSealed(media).decryptFailure == nil) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 9d206b8b5..b154b3341 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -773,3 +773,159 @@ struct ConversationMessageContentEncodingTests { #expect(throws: ConversationMessageContentEncodingError.self) { try widget.asProto() } } } + +@Suite("Media proto mapping") +struct ConversationMessageMediaMappingTests { + + private let blobID = BlobID(data: Data([1, 2, 3])) + private let blurhash = "L6PZfSi_.AyE_3t7t7R**0o#DgR4" + + private func media(withDownloadURL: Bool = true) -> Flipcash_Blob_V1_Media { + .with { + $0.renditions = [ + .with { + $0.role = .thumbnail + $0.blobID = .with { $0.value = Data([9]) } + $0.blob.image.width = 50 + $0.blob.image.height = 100 + }, + .with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + $0.blob.image.width = 100 + $0.blob.image.height = 200 + $0.blob.image.blurhash = blurhash + if withDownloadURL { + $0.blob.downloadURL = .with { $0.url = "https://example.com/blob" } + } + }, + ] + } + } + + private func mediaProto(caption: String?, redacted: Bool = false) -> Flipcash_Messaging_V1_Message { + .with { + $0.messageID = .with { $0.value = 50 } + $0.redacted = redacted + $0.content = [.with { + $0.media = .with { + $0.items = [media(withDownloadURL: !redacted)] + if let caption { + $0.caption = .with { $0.text = caption } + } + } + }] + } + } + + @Test("A media message maps its ORIGINAL rendition and caption") + func mediaMapsOriginalAndCaption() throws { + let message = try #require(ConversationMessage(mediaProto(caption: "from today"))) + + let expected = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: blurhash) + #expect(message.content == .media([expected], caption: "from today")) + #expect(message.repliedTo == nil) + #expect(message.redacted == false) + } + + @Test("A media message without a caption, or with an empty one, has a nil caption") + func mediaWithoutCaption() throws { + for caption in [nil, ""] as [String?] { + let message = try #require(ConversationMessage(mediaProto(caption: caption))) + guard case .media(_, let mapped) = message.content else { + Issue.record("expected .media content") + return + } + #expect(mapped == nil) + } + } + + @Test("A redacted media message stays .media with its blurhash, flagged redacted — never a tombstone") + func redactedMediaStaysMedia() throws { + let message = try #require(ConversationMessage(mediaProto(caption: "hidden", redacted: true))) + + guard case .media(let attachments, _) = message.content else { + Issue.record("expected .media content, got \(message.content)") + return + } + #expect(attachments.first?.blurhash == blurhash) + #expect(attachments.first?.width == 100) + #expect(message.redacted) + #expect(!message.isDeleted) + } + + @Test("A media item with no ORIGINAL rendition is dropped") + func mediaWithoutOriginalIsDropped() { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 51 } + $0.content = [.with { + $0.media = .with { + $0.items = [.with { $0.renditions = [.with { $0.role = .thumbnail }] }] + } + }] + } + #expect(ConversationMessage(proto) == nil) + } + + @Test("A reply carrying media unwraps to .media with the replied-to id") + func replyWithMediaUnwraps() throws { + let inner = media() + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 52 } + $0.content = [.with { content in + content.reply = .with { reply in + reply.repliedMessageID = .with { $0.value = 7 } + reply.content = [.with { $0.media = .with { $0.items = [inner] } }] + } + }] + } + + let message = try #require(ConversationMessage(proto)) + let expected = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: blurhash) + #expect(message.content == .media([expected], caption: nil)) + #expect(message.repliedTo == MessageID(value: 7)) + } + + @Test("Media content encodes to a single ORIGINAL rendition with its caption") + func mediaEncodes() throws { + let attachment = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: blurhash) + let proto = try ConversationMessage.Content.media([attachment], caption: "from today").asProto() + + guard case .media(let media) = proto.type else { + Issue.record("Expected media content") + return + } + #expect(media.items.count == 1) + #expect(media.items.first?.renditions.map(\.role) == [.original]) + #expect(media.items.first?.renditions.first?.blobID.value == blobID.data) + #expect(media.caption.text == "from today") + } + + @Test("Media without a caption encodes no caption") + func mediaEncodesWithoutCaption() throws { + let attachment = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: nil) + let proto = try ConversationMessage.Content.media([attachment], caption: nil).asProto() + + guard case .media(let media) = proto.type else { + Issue.record("Expected media content") + return + } + #expect(!media.hasCaption) + } + + @Test("Media with no blob, or more than one attachment, throws rather than sending a partial message") + func stagedMediaThrows() { + let uploaded = MediaAttachment(blobID: blobID, width: 1, height: 1, blurhash: nil) + let pending = MediaAttachment(blobID: nil, width: 1, height: 1, blurhash: nil) + + #expect(throws: ConversationMessageContentEncodingError.self) { + try ConversationMessage.Content.media([pending], caption: nil).asProto() + } + #expect(throws: ConversationMessageContentEncodingError.self) { + try ConversationMessage.Content.media([uploaded, uploaded], caption: nil).asProto() + } + #expect(throws: ConversationMessageContentEncodingError.self) { + try ConversationMessage.Content.media([], caption: nil).asProto() + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift index 5ba21c365..7f3376a1f 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift @@ -33,6 +33,7 @@ struct ConversationStoreMutationTests { case .cash: "" case .encrypted: "" case .widget: "" + case .media: "" } } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index c6e8b595d..86bafecac 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -79,6 +79,24 @@ struct MessageCapabilityTests { #expect(resolve(cash(from: them), isMember: false) == [.report]) } + @Test("My own photo can be replied to and deleted, but has no text to copy or edit") + func ownMediaOffersReplyAndDelete() { + #expect(resolve(media(from: me)) == [.reply, .delete]) + } + + @Test("Someone else's photo can be replied to and reported, but not copied") + func otherPersonsMediaIsReportable() { + #expect(resolve(media(from: them)) == [.reply, .report]) + } + + private func media(from sender: UUID) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 4), senderID: sender, + content: .media([MediaAttachment(blobID: BlobID(data: Data([1])), width: 1, height: 1, blurhash: nil)], caption: "hi"), + date: now, unreadSeq: 1, eventSequence: 2 + ) + } + private func cash(from sender: UUID) -> ConversationMessage { ConversationMessage( id: MessageID(value: 3), senderID: sender, diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift new file mode 100644 index 000000000..849f69b06 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift @@ -0,0 +1,218 @@ +// +// UploadPolicyTests.swift +// FlipcashCoreTests +// + +import Foundation +import Synchronization +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Upload policy") +struct UploadPolicyTests { + + // MARK: - Proto mapping - + + @Test("Maps version, TTL, and constraints in policy order") + func mapsProto() { + let policy = UploadPolicy(Self.proto(version: "v7", ttlSeconds: 300)) + + #expect(policy.version == "v7") + #expect(policy.ttl == .seconds(300)) + #expect(policy.constraints.map(\.pattern) == ["image/jpeg", "image/*", "*/*"]) + #expect(policy.constraints[0].maxSizeBytes == 5_000_000) + #expect(policy.constraints[0].image == .init(maxWidth: 2048, maxHeight: 2048, maxPixels: 4_000_000)) + } + + /// An opaque-blob entry sets no `kind`, and reading `.image` off it would + /// hand back zeroes that read as "unbounded" rather than "not an image". + @Test("An entry without image bounds maps to no image constraints") + func opaqueEntryHasNoImageConstraints() { + let policy = UploadPolicy(Self.proto(version: "v7", ttlSeconds: 300)) + + #expect(policy.constraints[2].image == nil) + } + + @Test("Maps the encrypted-upload constraints, and their absence to nil") + func mapsEncryptedConstraints() { + var proto = Flipcash_Blob_V1_UploadPolicy() + #expect(UploadPolicy(proto).encrypted == nil) + + proto.encrypted = .with { + $0.maxSizeBytes = 5_000_040 + $0.image = .with { $0.maxWidth = 2048; $0.maxHeight = 1536; $0.maxPixels = 3_000_000 } + } + + #expect(UploadPolicy(proto).encrypted == UploadPolicy.EncryptedConstraints( + maxSizeBytes: 5_000_040, + image: UploadPolicy.ImageConstraints(maxWidth: 2048, maxHeight: 1536, maxPixels: 3_000_000) + )) + } + + @Test("An unset TTL maps to nil, not zero") + func unsetTTLIsNil() { + var proto = Self.proto(version: "v7", ttlSeconds: 300) + proto.clearTtl() + + #expect(UploadPolicy(proto).ttl == nil) + } + + @Test("Picks the first constraint whose pattern matches, in policy order") + func constraintForMimeType() { + let policy = UploadPolicy(Self.proto(version: "v7", ttlSeconds: 300)) + + #expect(policy.constraint(for: "image/jpeg")?.pattern == "image/jpeg") + #expect(policy.constraint(for: "image/png")?.pattern == "image/*") + #expect(policy.constraint(for: "video/mp4")?.pattern == "*/*") + } + + // MARK: - Access context - + + /// `chatProfile` authorizes only a chat's current picture; message media + /// must go through the general chat scope or every read is denied. + @Test("Chat message media reads through the chat scope, not the chat profile") + func chatMessageUsesChatScope() { + let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + + #expect(BlobAccessContext.chatMessage(conversationID).proto.scope == .chat(conversationID.proto)) + #expect(BlobAccessContext.chatProfile(conversationID).proto.scope == .chatProfile(conversationID.proto)) + } + + // MARK: - Cache - + + @Test("A second request is served from the cache") + func cachesAcrossCalls() async throws { + let cache = UploadPolicyCache() + let fetches = Mutex(0) + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { fetches.withLock { $0 += 1 }; return Self.policy("v1") } + let second = try await cache.policy(for: owner) { fetches.withLock { $0 += 1 }; return Self.policy("v2") } + + #expect(second.version == "v1") + #expect(fetches.withLock { $0 } == 1) + } + + @Test("Concurrent requests share one fetch") + func coalescesConcurrentFetches() async throws { + let cache = UploadPolicyCache() + let fetches = Mutex(0) + let owner = try Self.owner() + + let versions = try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0..<5 { + group.addTask { + try await cache.policy(for: owner) { + fetches.withLock { $0 += 1 } + try await Task.sleep(for: .milliseconds(20)) + return Self.policy("v1") + }.version + } + } + return try await group.reduce(into: []) { $0.append($1) } + } + + #expect(versions == Array(repeating: "v1", count: 5)) + #expect(fetches.withLock { $0 } == 1) + } + + @Test("A different version echoed by the server forces a re-fetch") + func differentVersionInvalidates() async throws { + let cache = UploadPolicyCache() + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { Self.policy("v1") } + await cache.observe(version: "v2") + let refreshed = try await cache.policy(for: owner) { Self.policy("v2") } + + #expect(refreshed.version == "v2") + } + + @Test("The cached version echoed back keeps the cache") + func sameVersionKeepsCache() async throws { + let cache = UploadPolicyCache() + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { Self.policy("v1") } + await cache.observe(version: "v1") + let kept = try await cache.policy(for: owner) { Self.policy("v2") } + + #expect(kept.version == "v1") + } + + @Test("An expired TTL forces a re-fetch") + func expiredTTLRefetches() async throws { + let now = Mutex(ContinuousClock.now) + let cache = UploadPolicyCache(now: { now.withLock { $0 } }) + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { Self.policy("v1", ttl: .seconds(60)) } + + now.withLock { $0 = $0.advanced(by: .seconds(59)) } + #expect(try await cache.policy(for: owner) { Self.policy("v2") }.version == "v1") + + now.withLock { $0 = $0.advanced(by: .seconds(1)) } + #expect(try await cache.policy(for: owner) { Self.policy("v2") }.version == "v2") + } + + /// The policy is "the constraints in force for the caller", and the client + /// outlives a logout. + @Test("A different owner does not see another owner's policy") + func keyedByOwner() async throws { + let cache = UploadPolicyCache() + + _ = try await cache.policy(for: try Self.owner()) { Self.policy("v1") } + let other = try await cache.policy(for: try Self.owner()) { Self.policy("v2") } + + #expect(other.version == "v2") + } + + @Test("A failed fetch is not cached") + func failureIsNotCached() async throws { + let cache = UploadPolicyCache() + let owner = try Self.owner() + + await #expect(throws: ErrorBlob.self) { + _ = try await cache.policy(for: owner) { throw ErrorBlob.uploadDenied } + } + #expect(try await cache.policy(for: owner) { Self.policy("v1") }.version == "v1") + } + + // MARK: - Fixtures - + + private static func owner() throws -> KeyPair { + try #require(KeyPair.generate()) + } + + private static func policy(_ version: String, ttl: Duration? = nil) -> UploadPolicy { + UploadPolicy(version: version, ttl: ttl, constraints: []) + } + + private static func proto(version: String, ttlSeconds: Int64) -> Flipcash_Blob_V1_UploadPolicy { + .with { + $0.version = .with { $0.value = version } + $0.ttl = .with { $0.seconds = ttlSeconds } + $0.mimeTypeConstraints = [ + .with { + $0.mimeTypePattern = "image/jpeg" + $0.maxSizeBytes = 5_000_000 + $0.image = .with { + $0.maxWidth = 2048 + $0.maxHeight = 2048 + $0.maxPixels = 4_000_000 + } + }, + .with { + $0.mimeTypePattern = "image/*" + $0.maxSizeBytes = 10_000_000 + $0.image = .with { $0.maxWidth = 4096 } + }, + .with { + $0.mimeTypePattern = "*/*" + $0.maxSizeBytes = 1_000_000 + }, + ] + } + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatCipherBlobVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatCipherBlobVectorTests.swift new file mode 100644 index 000000000..06872e97b --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatCipherBlobVectorTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +import SharedCore +@testable import FlipcashCore + +/// Photo-blob half of `test-vectors/chat_cipher.json`. The canonical copy lives in the orchestrator +/// repo; this one is synced. The vectors fix the nonce, which production draws at random, so only +/// the decrypt direction is reproducible: each blob must open to its image, and each reject must not. +@Suite struct ChatCipherBlobVectorTests { + + struct BlobVector: Decodable, Sendable, CustomTestStringConvertible { + let name: String + let chatId: String + let chatKey: String + let senderPublicKey: String + let recipientPublicKey: String + let blobId: String + let image: String? + let blob: String + + var testDescription: String { name } + } + + struct Member: Decodable, Sendable { + let edSeed: String + let edPublicKey: String + } + + struct ChatKeyVector: Decodable, Sendable { + let name: String + let chatId: String + let memberA: Member + let memberB: Member + let chatKey: String + } + + struct Rejects: Decodable { + let decryptBlob: [BlobVector] + } + + struct Fixture: Decodable { + let chatKey: [ChatKeyVector] + let blobs: [BlobVector] + let rejects: Rejects + } + + static let fixture: Fixture? = try? loadFixture() + static let blobs: [BlobVector] = fixture?.blobs ?? [] + static let rejects: [BlobVector] = fixture?.rejects.decryptBlob ?? [] + + private static func loadFixture() throws -> Fixture { + let url = try #require( + Bundle.module.url(forResource: "chat_cipher", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + let fixture = try Self.loadFixture() + #expect(!fixture.blobs.isEmpty) + #expect(!fixture.rejects.decryptBlob.isEmpty) + } + + private func decrypt(_ vector: BlobVector) throws -> Data { + let bytes = SharedBytes.shared + let image = try DefaultChatCipher.shared.decryptBlob( + blob: bytes.byteArray(data: Data(hex: vector.blob)), + chatKey: bytes.byteArray(data: Data(hex: vector.chatKey)), + senderPk: bytes.byteArray(data: Data(hex: vector.senderPublicKey)), + recipientPk: bytes.byteArray(data: Data(hex: vector.recipientPublicKey)), + chatId: bytes.byteArray(data: Data(hex: vector.chatId)), + blobId: bytes.byteArray(data: Data(hex: vector.blobId)) + ) + return bytes.data(bytes: image) + } + + @Test(arguments: blobs) + func blobDecryptsToItsImage(_ vector: BlobVector) throws { + #expect(try decrypt(vector) == Data(hex: vector.image ?? "")) + } + + @Test(arguments: rejects) + func rejectedBlobFailsToDecrypt(_ vector: BlobVector) { + #expect(throws: (any Error).self) { try decrypt(vector) } + } + + /// `ChatSeal`, built from the members' seeds the way the app builds it, opens the same blobs: + /// the recipient through the peer's key and the sender's other device through its own. + @Test(arguments: blobs) + func chatSealOpensBlobForBothMembers(_ vector: BlobVector) throws { + let pair = try #require(Self.fixture?.chatKey.first { + $0.chatId == vector.chatId && $0.memberA.edPublicKey == vector.senderPublicKey + }) + let alice = try KeyPair(seed: Seed32(Data(hex: pair.memberA.edSeed))) + let bob = try KeyPair(seed: Seed32(Data(hex: pair.memberB.edSeed))) + let aliceID = UUID() + let conversationID = ConversationID(data: Data(hex: vector.chatId)) + let image = Data(hex: vector.image ?? "") + let sealed = SealedBlob(senderID: aliceID, plaintextSize: image.count) + let blobID = BlobID(data: Data(hex: vector.blobId)) + + let asBob = try ChatSeal(cipher: DefaultChatCipher.shared, owner: bob, peerPublicKey: alice.publicKey, conversationID: conversationID, selfUserID: UUID()) + let asAlice = try ChatSeal(cipher: DefaultChatCipher.shared, owner: alice, peerPublicKey: bob.publicKey, conversationID: conversationID, selfUserID: aliceID) + + #expect(try asBob.decryptBlob(Data(hex: vector.blob), blobID: blobID, sealed: sealed) == image) + #expect(try asAlice.decryptBlob(Data(hex: vector.blob), blobID: blobID, sealed: sealed) == image) + } +} + +private extension Data { + init(hex: String) { + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + data.append(UInt8(hex[index.. Fixture { + let url = try #require( + Bundle.module.url(forResource: "chat_media", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + #expect(try !Self.loadFixture().constraintSelection.isEmpty) + } + + @Test(arguments: vectors) + func firstMatchIndexMatchesTheCrossPlatformVector(_ vector: Vector) { + let result = ChatMediaConstraints.firstMatchIndex(patterns: vector.patterns, mimeType: vector.mimeType) + #expect(result == vector.expectedIndex, "\(vector.note)") + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaDownscaleVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaDownscaleVectorTests.swift new file mode 100644 index 000000000..47a16e8c5 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaDownscaleVectorTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import FlipcashCore + +/// Downscale half of `test-vectors/chat_media.json`. The canonical copy lives in the +/// orchestrator repo; this one is synced. A failure here is either a real regression or a +/// deliberate cross-platform decision that has to be made in the canonical fixture and +/// re-synced to both platforms — never a local edit. +@Suite struct ChatMediaDownscaleVectorTests { + + struct Vector: Decodable, Sendable, CustomTestStringConvertible { + struct Expected: Decodable, Sendable { let width: Int; let height: Int } + let name: String + let sourceWidth: Int + let sourceHeight: Int + let maxWidth: Int + let maxHeight: Int + let maxPixels: Int + let expected: Expected + let note: String + + var testDescription: String { name } + } + + struct Fixture: Decodable { + let downscale: [Vector] + } + + /// Loaded when the suite is built, so each vector runs, and fails, as its own case. A fixture + /// that is missing or doesn't decode fails `fixtureLoads` instead of every vector at once. + static let vectors: [Vector] = (try? loadFixture().downscale) ?? [] + + private static func loadFixture() throws -> Fixture { + let url = try #require( + Bundle.module.url(forResource: "chat_media", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + #expect(try !Self.loadFixture().downscale.isEmpty) + } + + @Test(arguments: vectors) + func targetMatchesTheCrossPlatformVector(_ vector: Vector) { + let result = ChatMediaDownscale.target( + sourceWidth: vector.sourceWidth, + sourceHeight: vector.sourceHeight, + maxWidth: vector.maxWidth, + maxHeight: vector.maxHeight, + maxPixels: vector.maxPixels + ) + #expect(result.width == vector.expected.width, "\(vector.note)") + #expect(result.height == vector.expected.height, "\(vector.note)") + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_cipher.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_cipher.json new file mode 100644 index 000000000..ca8cd038c --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_cipher.json @@ -0,0 +1,438 @@ +{ + "algorithm": "flipcash-dm-e2ee-v1 / X25519_XCHACHA20POLY1305", + "spec": "EncryptedContent in flipcash2-protobuf-api proto/messaging/v1/model.proto. aad = 'flipcash-dm-e2ee-v1' || chatId || senderPk || recipientPk; blob aad uses 'flipcash-dm-e2ee-blob-v1' and appends blobId. All fields are lowercase hex unless noted.", + "note": "Cross-platform parity gate for ChatCipher (:libs:encryption:chat-cipher). `content`/`plaintext` are serialized protobuf Content bytes; the crypto never parses them. Nonces are fixed here; production nonces are random.", + "keyConversion": [ + { + "name": "alice", + "edSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "edPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "x25519PrivateKey": "70788f1a0cea001a2631dae5d05dbd062008d5b30f50b9e29beb2a7822289044", + "x25519PublicKey": "4a3807d064d077181cc070989e76891d20dca5559548dc2c77c1a50273882b38" + }, + { + "name": "bob", + "edSeed": "65666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f8081828384", + "edPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "x25519PrivateKey": "d8dbfc120cc56970a8c144b559dcb86677e786abb1b278003762986791f7c06a", + "x25519PublicKey": "ad6c082b1b7d59403617c495d135151af3dd8936fc6c3e07de914b55c8b64f5d" + }, + { + "name": "carol", + "edSeed": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "edPublicKey": "5b9437adeaffbe8f41b13d96ed49d2f51cd6c266cd8ecc284b0552ec4912b8dd", + "x25519PrivateKey": "f8b9cc73f9fa1817e212ff1bffe6b504f193a265243f910fe61cf2eed9380067", + "x25519PublicKey": "8c66226c3938112135bf2c7b02b3ed3fafd31ab7fc774ee6448aa9a2ff71e923" + }, + { + "name": "rfc8032-test1", + "edSeed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "edPublicKey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "x25519PrivateKey": "307c83864f2833cb427a2ef1c00a013cfdff2768d980c0a3a520f006904de94f", + "x25519PublicKey": "d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e" + } + ], + "chatKey": [ + { + "name": "alice-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "memberA": { + "edSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "edPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "memberB": { + "edSeed": "65666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f8081828384", + "edPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0" + }, + "sharedSecret": "f4220c7019c0ae5bb8011a5058e871f1166b777d2903d5c3a4793d9a9b084940", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "note": "identical from either member's side" + }, + { + "name": "alice-bob-other-chat", + "chatId": "9027a70126863980b94e9b31ecc25b7c2a48723af73662f6c297939dd2ed6e0e", + "memberA": { + "edSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "edPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "memberB": { + "edSeed": "65666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f8081828384", + "edPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0" + }, + "sharedSecret": "f4220c7019c0ae5bb8011a5058e871f1166b777d2903d5c3a4793d9a9b084940", + "chatKey": "0c5e7a136858058a4aeee4252bbc906226b8308ea3eed2c67b58a2b2015e7920", + "note": "identical from either member's side" + }, + { + "name": "alice-carol", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "memberA": { + "edSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "edPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "memberB": { + "edSeed": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "edPublicKey": "5b9437adeaffbe8f41b13d96ed49d2f51cd6c266cd8ecc284b0552ec4912b8dd" + }, + "sharedSecret": "4659cd4eb4e457bef2ca2b6d3a14ab447f21b427c9403824634cf81d0bac893f", + "chatKey": "aee337958634ffa8401fe02ef1d3f2880f0886838a757daffecfdee10e10e19d", + "note": "identical from either member's side" + } + ], + "messages": [ + { + "name": "text-ascii/alice-to-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "content": "0a070a0568656c6c6f", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "b81415e496a7c65aca660ab329de4a5aa66a90579b9fabf5ae" + }, + { + "name": "text-ascii/bob-to-alice", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "recipientPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "content": "0a070a0568656c6c6f", + "nonce": "bcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15eda29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d079b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "ciphertext": "365edbb5cd2a9e6101f85bfd36f95e28c08b5ad035b9170694" + }, + { + "name": "text-unicode/alice-to-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "content": "0a140a1268c3a96c6c6f20f09f988020e4b8ade69687", + "nonce": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "b97444a6449240b4f35a2465fddcedfa4a1338a422d03fb1b9428b3d9549c31901bc7cecf23c" + }, + { + "name": "text-long/alice-to-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "content": "0a80040afd036c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e67206c6f6e6720c3a9e4b8adf09f9880", + "nonce": "0e0f101112131415161718191a1b1c1d1e1f202122232425", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "35d7df9422937a5b06c4e23ce805fc57851bf5ea0de216b34224bef129947079def6f6eab35d288e7543c58cab98637f89d444eeda2e6dd1e6d4d7c43d1e302a22ab2f81e302d154d4a7d5052484633ac267eefbecae6137015e5a5d474ce38a95dc20d68114bf484dc1668175498a6c63f725af40fa88393c817fb2656f97f7996842a2ae2638463984bd9b5766c2053bb283dc8a866715ae3636998da5c2b2be656b6b07e1378de6b707ad8153344987991492f922a1c70b93b0fe40e30183efa9d21d8b1748fef91b40dbe282e6bd2f0faa335d54bd06e36d1a97b0e071564909beb2630e0b5096236bca8ba7bbe34b772433054321350f2f38c7705285f301d29ab4808dbadfb561400fe82b852ca69bc72be984eb94c23d0c8a1e445af163a7717f166a600ab42d86572849dd389edcf762d9128b29ec6e80b801f7159e398e1baf03e1e00121d5c44fbbd15a9c7fe62106ac856a0d4d68ac943f904b97466d60cf6567e2cc1a5a78c28a155b001b8f80db861d8c4d25274038de09f76f9e002115675d27c868ca4b9e7efeb1ccc698655b63d183a3eef1776be306c6581300cb1121d63ad4166aabbbdd9b6084e5a67dcbabb23399077344e72c28be745f61d9ed2c35ca7be68bb7f05c83f4138cb10bd01476e8fc2055cdd835d4284ffda8b18c0012dd99ac5bb20080764812888901131e6cf775a6ba04be296a42041fdf0449add1b513fc73972bc41edbfdf98553" + }, + { + "name": "reply-to-text/alice-to-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "content": "1a120a02082a120c0a0a0a087265706c79696e67", + "nonce": "15161718191a1b1c1d1e1f202122232425262728292a2b2c", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "a754be8c2f7973dd8c039f2391e047a0843f82983a803c95fc471a7f66b79ed6b9609efd" + }, + { + "name": "reply-to-media/alice-to-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "content": "1a630a020807125d225b0a510a4f0a4d080112120a10a0a1a2a3a4a5a6a7a8a9aaabacadaeaf1a350a0a696d6167652f6a706567108010222408800510e0031a1c4c454856366e574232796b3870796f306164522a2e376b434d646e6a12060a046c6f6f6b", + "nonce": "1c1d1e1f202122232425262728292a2b2c2d2e2f30313233", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "ac0c7f4412c9460f2edc49af54845acf0c7974d0a0675531809acbbfa3d69efedf9f910031d5283b1dd887eea937d8247af80af83724ea2ded914f706924a6f230d3d3b50fa638c1467afefc82d6a6dba291670c1c1a100c206429d1868f688a889b18dfed6fec14c46fb7b2c9ca7fec7d791a034d" + }, + { + "name": "media-with-caption/alice-to-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "content": "22610a520a500a4e080112120a10a0a1a2a3a4a5a6a7a8a9aaabacadaeaf1a360a0a696d6167652f6a70656710c0c40722240880081080061a1c4c454856366e574232796b3870796f306164522a2e376b434d646e6a120b0a09612063617074696f6e", + "nonce": "232425262728292a2b2c2d2e2f303132333435363738393a", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "4ab537c7488ad8ba2920b6cbe24ad7b11b54e97c3b555131c6de47a68652e047f756bd5ea0ba10628a7c59a2f1caf39bfa804a3815b4cf572b8b1fdaedefc5a854bd9bfd0df5cf6543a9b049111e6a1847068ab769f6219f2e9ed30630786b304f861e0e6c53fb6b8b4b8bed6217421eaae0b3" + }, + { + "name": "media-no-caption/alice-to-bob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "content": "22310a2f0a2d0a2b080112120a10a0a1a2a3a4a5a6a7a8a9aaabacadaeaf1a130a09696d6167652f706e671063220408011001", + "nonce": "2a2b2c2d2e2f303132333435363738393a3b3c3d3e3f4041", + "aad": "666c6970636173682d646d2d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "98d4a798de8f047f8323779d12824dfe9623a312fc76be8d53cd97926734949ec939e84197942e91ff71363d40d208338e3eda5f3dca803dba7e2883541e8ae5a5e978" + } + ], + "blobs": [ + { + "name": "empty", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "image": "", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d646d2d653265652d626c6f622d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "blob": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f49f238db29bd32172ba692003faac4c9" + }, + { + "name": "small", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "image": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7", + "nonce": "7f808182838485868788898a8b8c8d8e8f90919293949596", + "aad": "666c6970636173682d646d2d653265652d626c6f622d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "blob": "7f808182838485868788898a8b8c8d8e8f90919293949596860fbbbcc67390506bbf4ab45371f88340950f328b6579bacd333b2b2ebf1c38b09f22e213f4402f93812474672094caef33bb48a8f981789ebf22756d518253ac7cf4f44b165add56ab28206c0fda7636fb0f98f1a66172130b7a057f4ccda7f4e202151b5430779a1c79a6a954e59be0b8691d9b20b576f5df8257e6214bf6d433de712b10ddda50c09f059c7bda06ddbf04fef33fc23260e67ea05ee8dd4fd69c78c6277616ed895bec587fec5816d93e4da28648533347fd0c30b755dd5a5f703996b2f2fd4da6378889123223b06976fd823dd9513c" + }, + { + "name": "4k", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "image": "001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1001f3e5d7c9bbad9f81736557493b2d1f00f2e4d6c8baac9e80726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8aa9c8e70625446382a1c0dffe1d3c5b7a99b8d7f61534537291b0cfee0d2c4b6a89a8c7e60524436281a0bfdefd1c3b5a7998b7d6f51433527190afceed0c2b4a6988a7c6e504234261809fbeddfc1b3a597897b6d5f4133251708faecdec0b2a496887a6c5e4032241607f9ebddcfb1a39587796b5d4f31231506f8eadcceb0a29486786a5c4e30221405f7e9dbcdbfa1938577695b4d3f211304f6e8daccbea0928476685a4c3e201203f5e7d9cbbdaf91837567594b3d2f1102f4e6d8cabcae90827466584a3c2e1", + "nonce": "868788898a8b8c8d8e8f909192939495969798999a9b9c9d", + "aad": "666c6970636173682d646d2d653265652d626c6f622d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "blob": "868788898a8b8c8d8e8f909192939495969798999a9b9c9d8de7a55a21aad423d39573ec749c3fcaab95ab84f0ed8f922c704588e793e6eefe6bd04bc58cb391a1375e036e2a02709222b64c67642064fb2b8618526d2e0d88d6d082ac92ce764fdb69b9202c8d2c3de61abe75f1fa6f1a13da6727511628da7df9fc90cd2d73aefe7ef3f13411a1861312cc11efec0472221356fd4847feb695f7533561156d349fab096f76cca23488146d68fd8b9d65df3da49e2c463832ce2a7cdfdfb9b48449740e1019132c5a17624c1e049089319c26e8dca4c3f4c309ddafc18bc956ed4d23d78528b1153767b6f506bba5e5b547f2c85157f36ee54061bcbbaccf8973ad0a70933517f17117cd9bd269c3a9214c2421363d9c29e09e5e394ef182e17edd63709659f116838dbd52a5c8ab64d3ba57d2742b2a1e5651a91de516d8da34a23c29db3eacae26f60498ee24b15160abf881a50240d717b742491df7f46a51f403416abc7026727ebae98fd189b69f4d37ca8f18d577777bc28e50b4ef41a4c64dd5073cd8c6dee529c4d2b0d76c86bd19f82183c6b5a6054bd9133cb3ec6f0946e702cc28cc667aef95b84f9a355c9491b80afe51e56fe0d8a2fd3ba090e7989ddb3371aba4b7b9208e5126bd5a89505e1f6b15789e1e638488a0d11c52ed74c6c317bc18e13b796e6db4c62988c0af752d870065aa0cffca55e95b77ae6ba70442b707eb514bae50f494a2e3e6210d61f6660bf0652511baaf270cceecbe390d937bd5ab20eca727482be02dc32bb8d64e573ea8bff05c185aa926490c058c4427cef5573fa52c1bbc45fe79e4a3621ec9d07be1a42442d3c1b7e4b6759dc9f81a3f0ea3c1b14a85201ab9f677e7b5d1c0302a13287fb9b42336233e71e57dd834f2650790cf63f57e0050587093c3d8ff6f73abfa5ba543bc4923fec5deb0c4b5db66f346bead43935f9b390056874cbbe0afcf30fd88f0412a7b9a25b076f1f0f654ce9de4a8423c7b7ac75aceca99c34b3c41828c904020f96c1ae9f0cef83b3c9d7cf2a26d9ad4541692c4a7099ad92f2ea624868e0c591e59e62558e7f18306bcfd6178e026a871876f2a651f1515da1c67ed044bfc020851f26f64a92a9f9cb3b5d9d5f28b37927bca80895e7eea4d3b423581e78af04a0521491320db14b52904778968715ed0fe3d0e5bdabce24c8718c1d954684fea9823a5a7986a26b073f1a8a72534ac51e596acaee06f53dcb89d43ed5c52b02a4330ae7ef77a8879037a8b7034c21bc3b3e18148449c157e2903f46740fc5e6b6730eb759501b48a547f5ccfda40c77b0bc0ffe13f61bbb04e07f7129ea161e6788cc3da217709689fd7760cd394d7bea217fdce3fbcb453ea1b321c70c51f630338fb88f15efaff8634d039b02726d0759ed1f940d30e61445c0a23ad6d69c57f1e9856755a48a2f6276089be0c279cf7b82dbe309f4e69c6d82aeb47ad1d4a72b374e8b895ad2a2c5577886cf8d6bef51066c2d0a922caeced014e50daf06764668c0585e98acbcf1d941c054976606be27ef98794aa5ad54d4cd9c9605cfffa2d93b00dd5ba0a8fc3e0483fc8912727600a3e9ab70d4bd766afd671e8bb8c39680453a514ecf9b0209292ae58228acceee44108ec4aec1101d5109a1b2c3f4afb3266bc5ff1ce70b77a4289377a79890997c012f214ccb5d174ff2e65a881b7cb2c7147d3aa285fe75a9f7f8e4540a2f2e61166d967cce8223f9374d6e3ae53184abd68d30b5a15b7f2e8e4f5f3dbe76666aee335518bd9b05f9b911bc7babd7aa33c1ecb21732d66be507a362fdcee00777af78e04bbc55ad4819b7b007dade4a6bc32a2ba70f02f64fdbacbf93d69de3dfbde89aba0eb55e5dad361ec164659386af13aeb702271198b83503dcd67f299f4d714f3106ddab4763c5df0a520838f05a2b066bbe32b996939214309b3980dfabd903f86f861dba3495f7fa8b388c260b059e5bd26153a39710a44a71dd3aeaa1255eaf7b7be22ba0025919162bb74c9c4ea697a366341fb0f66a192385bf3327166082034a30dc4d8441dc6988afa655c933a922164a832d22021f50e9a01ffc9c66eeba6d8b1cb346422a969153a10caff36251adfe2f3f4f4ec047557b70b0498fc221a2434daeb81788bb3ee08b58d810c28daaaea0e4a09513152d3d0a20fc3e64e5ffbb0a02cdb9a6c6fffc2dc603c1e9a0f3ebf1b352bc17b7ecb76d101b22903aaa55bd1c70bbe69ef929ec9f10f0fc7ae2f6c103039d6523f7178434680b6d50078a705126d423ecc97de0955033a49e8e0131a0bc2c73c0ef1bd5f87b43a92288bea65c838e3bbf3c8fd8b91e39c8594b7f8ae5b6019200f82381fb23b59b2c875b3ceffeedf7ca40af5bbeb6aa388d3733b98f2c2e6db1913164b5d76d79d7aa9ebf7fb435195b87150f49e624b24bb52570ffbf205778670679e8c83180d193192ef0fd84911c0f626f8b59abfd994737140ef22ba79517cee3268c07d2253f67d05d5e6bc9fbaf77c0febee92c05ac28279822e82820e9df574db5456f38424bab9520f6526676de7df19878385c1105aeeb0c54512a8076272a136150e99e3e2800a953698cb73f6790050dae4352392700b772ae2ae22330f8735a017bee1c43b6a7001109f84c78d17ce2c5a023283b96cdc9b0e0c146930586045336c7b3b8e4630a838b3b202e9a61ef93e1a36afbc0f380b2043c8fdc066799c5f65be9ed1043ee5329e7ae92448fbab4e4a89c2e0b9dfa8b06f0e048fb536d372283ccc92cffdb94379975c120362e51313b01093f3be8f09a83bc601d306983426c97f3478028900cb97c884a8fb999ea197e3c3660cd7540f9ef128d5debc6da5179c92ffd505b96ebb073760920b9052b908fc012cc00ed4248c2e294544dfc1ad8a7c9cf4d27a8e00e488c443961656aeecd910f7e54ab0c1e38749fc4ba0def5fa9996c43b48caa389376efd0fd58ddb8000d666e62334b38109356e1129703afbd5bf73c4683cdb5587e045994a2065d579f396481f059d673eae1eb0c3cdb2fa77e2d43f12380b0d9210f4c1e77bc4fe7d4600e320d3cb9ea030f39788c05021f3e921c022863ca581d679745991d49c4064046a1c69c3406e75deae5265aaf7efa83625468cf1850747f4d4e3e89f6a386552b24a0e779fe7a44264aa6ef8bcc30c042ff6537a419df7a7fbc9790fc9b7e309cb486c201f37e159440de009b65a3a07203810434ce2f171aa3019bab617de5aa087caedd9515e0469bb35c592b733c4ec22a77666a491af42cffc247cfc9ef540e8d22cc9bc0aa62df7460af141b626e84bffaa2ae41df27254f33d1d8d3283a88f64a9e57ff10e283a74a652c29bc15647202c6c31254ba4e0de38312f8251b63238b270558bc239bbaebe057f062f08aab74c9a191ad57a2094ec6e3c21d70d8c74f8a349ec1455b3b30d86e70720bbc182a465020b65d3f78351f7d2ddaf7491467fa36e3babca96f316d0a498b20a91adcdb5cf978b60f2be7e35653404a6843b3703370e8613c4057f73de4953f25720aa08ba05a8c156e52edf73297d15844264b55f9380d6c82daf56ea1a45e60cba2cd3ac3c10e93b7d46f1fa8fa97aa24d22486209a0a98e3f25a17064e1890bb831c6454e012af6a327ad9dd29aeb9ff40d6d29e4aaeeec81488d4821c967d83418dd15608876f9aa9f96a8ca1fd1a4f6cdf905d1d27029dba339c2cf55368e7a3abb8ae4ba78ea408084bc90c1a5d1735a13906f569cca1d1cce28c37d861f10fe08aaf98af60ae01b0fa91b3e0d9d1cc87c9dfe21e3d9032e638ad14f3aa972c59d1bed654d02f1f6feda0942a5bab2755294e2d689df7685e31d1c2328c6b467f6d9fa21eb8d67cb06ec966d4d40070d6348ffa3b822502e7bbcfe9ae4a8c71fa884cc22c68cecdb0989996025f6a03cc4018ad4ebeb5c292895f07404874f9e93d9ea411f9e8f98bf1e93bf7585b67e54abdb8e4447f3148beaf17e33a3f6d2dedb5e198adb82550e6cb05baea48b2fbc4a0945d87e5d4624826b0bde5eb37ade8cd07a7c899d606897c3862197e32b7e731487656cfc0ff391b8e68e30bef29b8b72db825495db967a06914cba8b2e86e683850026b35fbda04df6c2d70bfbe34b59cd49e856825751b80c2b2261aaa7c4b9af34003132965bcf73b56667b76ce8b5325cc425d3e65182ba2a8fa708685220aa5903f8ba8d7cdcdae8cfd604aa58c5443be60a7f8fe9cf3be3aec5ee7d20a84a2906d7907d47f3ca45e6bb30c36607844207a825c58194c3d2cf6e8e7164fe1483747a2d5a143ffeb4d76dc8ca54f1b035bc739273c6ce2580cdebc58ae8253ace5d624fd0cd5bac9177fae8288ec3dc79ba86b52f53808cbde5163541572e11d84ff05457dc98400c6e3f9cf9ed26ee514e501177fdadb22828e07b1d2f4bb0f98c3d9ee9a8657089959618b8ba96ed5cfe96235fd415bb75ee2ba169973fe1bafa7c8ebc5c3fe05c0687ffb30bcaabedb00572ea5e85031657db1b83fd3f813b5d215574ccc14b975253c76e0486c5fc826d0d824739a09d9b39fcbf904bfc951f6c5a94b324fd6da278953158abed0caecb5ba657f4a3a41dd46ec1a2543af82be49b22c844cb103592d734a07ee3908c0a7ccf5a518fcb80fff57c0c79948c3fed5a25415fadcdc62ee37dc07397d678c9e41acf9bb2f54e36430df2fc29e9f6e8c8f71fec99df4b4d44d197dd3dbcb2f5d79526ff0f2c1b9e50035d9b1373886c5497a50ea90d87aa7e114b05db35f746c198a3381497ef100c0d26763cd93e2e1542352fe1568cfbce607d0977e276d703d61e90c7fe797e91e282b57aa352e72cda761ea568dfb30d526e58c006a31571e0b075ff3db231d7e41e9451b6a37539ce206464fb50345a9d774570845a082651dd84710134d9a7fd870622af71535b24fb32452ac9173fd11a937b117f3985e0b5bda3fa031c924e115ead72235a007f4798c4ddeb31ec13226f409dedcaf57b2d5835858461fcb407dc2210dfb7b682ad8620392be76993885a2307360e3fd9cae7badd3b07f1c60f6e773d9016ab3887ea6a1b1d3024ee4b27eadbf5924ce9bb91151f1377c601e96161da6f9a83489f2442dc6ead2b752efd45ea47a6d018ba3aa4f8707992d08e7457811284a546e6ea6ff3a3247bf6d1ae918ad1967f281360040313da36de664d853b45742918464dba600ac088a9088d3e9f1477268797c168f318d8c34a64977e668f7422516c57c1bfe2086212bc659fe582e4619fbfc14dc19463f389d670c0aac12d9df2c3112ec7924122bdeb887f16fcd002011eacdc2951af4d01bc8fa3b668e1679e5c96e23dd877be24d84a21e37f7e60e7e1f15eb19f8c0501f89205eb0b8f179e97a32c6125c8286a21b307480197d4d95ba3782a8039061ed68a9a55f7ea44ed77a998827d9b89814db8ed3dc23be8794b7246f3c824a5e72812bd583a45525312cc0aed306e0aa8846bfed2850dc112d8dbd3aaf832b309918c5ceace3102948ac4b4d200e4c42aab28288213c3ed8baac3c39b9139ab1bf12ab6c3cf2dd48095eaf8243d908093ce8e647ca289b17296dd46a880bf62fff5c083cf1879472e098e059dec47d5c6f851c15e53ecee6e3ebde177d220a1a80c60f6dd0e08fb91196f216057ee115444f4d09becdcb11b407de1d328324373d64acc88559adeafcec649e56d287a6c43a976ff0dc1f2337a60b9f18b949996b4ca0300617a57e62e4a3f07757640c965239af074656b1b1a262d965be346acbfa9e89069a66ab1021db79fc0d698502a2e1e11381bb181" + } + ], + "rejects": { + "invalidEdPublicKeys": [ + { + "name": "identity-order-1", + "edPublicKey": "0100000000000000000000000000000000000000000000000000000000000000", + "reason": "low-order point", + "ownSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e" + }, + { + "name": "order-2", + "edPublicKey": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "reason": "low-order point", + "ownSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e" + }, + { + "name": "order-4-zero", + "edPublicKey": "0000000000000000000000000000000000000000000000000000000000000000", + "reason": "low-order point", + "ownSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e" + }, + { + "name": "not-on-curve", + "edPublicKey": "0200000000000000000000000000000000000000000000000000000000000000", + "reason": "not a valid point", + "ownSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e" + } + ], + "zeroSharedSecret": [ + { + "name": "peer-x25519-u-zero", + "x25519PrivateKey": "70788f1a0cea001a2631dae5d05dbd062008d5b30f50b9e29beb2a7822289044", + "peerX25519PublicKey": "0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "decrypt": [ + { + "name": "tampered-tag", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "b81415e496a7c65aca660ab329de4a5aa66a90579b9fabf5af" + }, + { + "name": "tampered-body", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "b91415e496a7c65aca660ab329de4a5aa66a90579b9fabf5ae" + }, + { + "name": "swapped-sender-recipient", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "recipientPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "b81415e496a7c65aca660ab329de4a5aa66a90579b9fabf5ae" + }, + { + "name": "wrong-chat-id", + "chatId": "9027a70126863980b94e9b31ecc25b7c2a48723af73662f6c297939dd2ed6e0e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "b81415e496a7c65aca660ab329de4a5aa66a90579b9fabf5ae" + }, + { + "name": "wrong-nonce", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "nonce": "b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcc", + "ciphertext": "b81415e496a7c65aca660ab329de4a5aa66a90579b9fabf5ae" + }, + { + "name": "shorter-than-tag", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "000000000000000000000000000000" + } + ], + "decryptBlob": [ + { + "name": "tampered-tag", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "blob": "7f808182838485868788898a8b8c8d8e8f90919293949596860fbbbcc67390506bbf4ab45371f88340950f328b6579bacd333b2b2ebf1c38b09f22e213f4402f93812474672094caef33bb48a8f981789ebf22756d518253ac7cf4f44b165add56ab28206c0fda7636fb0f98f1a66172130b7a057f4ccda7f4e202151b5430779a1c79a6a954e59be0b8691d9b20b576f5df8257e6214bf6d433de712b10ddda50c09f059c7bda06ddbf04fef33fc23260e67ea05ee8dd4fd69c78c6277616ed895bec587fec5816d93e4da28648533347fd0c30b755dd5a5f703996b2f2fd4da6378889123223b06976fd823dd9513d" + }, + { + "name": "swapped-sender-recipient", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "recipientPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "blob": "7f808182838485868788898a8b8c8d8e8f90919293949596860fbbbcc67390506bbf4ab45371f88340950f328b6579bacd333b2b2ebf1c38b09f22e213f4402f93812474672094caef33bb48a8f981789ebf22756d518253ac7cf4f44b165add56ab28206c0fda7636fb0f98f1a66172130b7a057f4ccda7f4e202151b5430779a1c79a6a954e59be0b8691d9b20b576f5df8257e6214bf6d433de712b10ddda50c09f059c7bda06ddbf04fef33fc23260e67ea05ee8dd4fd69c78c6277616ed895bec587fec5816d93e4da28648533347fd0c30b755dd5a5f703996b2f2fd4da6378889123223b06976fd823dd9513c" + }, + { + "name": "wrong-blob-id", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "blobId": "00000000000000000000000000000000", + "blob": "7f808182838485868788898a8b8c8d8e8f90919293949596860fbbbcc67390506bbf4ab45371f88340950f328b6579bacd333b2b2ebf1c38b09f22e213f4402f93812474672094caef33bb48a8f981789ebf22756d518253ac7cf4f44b165add56ab28206c0fda7636fb0f98f1a66172130b7a057f4ccda7f4e202151b5430779a1c79a6a954e59be0b8691d9b20b576f5df8257e6214bf6d433de712b10ddda50c09f059c7bda06ddbf04fef33fc23260e67ea05ee8dd4fd69c78c6277616ed895bec587fec5816d93e4da28648533347fd0c30b755dd5a5f703996b2f2fd4da6378889123223b06976fd823dd9513c" + }, + { + "name": "message-ciphertext-as-blob", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "blob": "000102030405060708090a0b0c0d0e0f1011121314151617b81415e496a7c65aca660ab329de4a5aa66a90579b9fabf5ae" + }, + { + "name": "shorter-than-nonce-and-tag", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "blob": "000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "decryptBlobAsMessage": [ + { + "name": "blob-bytes-as-message", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "chatKey": "6c2005541fb82abe2f639ae77e3b146e7f0dd97a9b7d3b2dccef000c69e9df22", + "senderPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "nonce": "7f808182838485868788898a8b8c8d8e8f90919293949596", + "ciphertext": "860fbbbcc67390506bbf4ab45371f88340950f328b6579bacd333b2b2ebf1c38b09f22e213f4402f93812474672094caef33bb48a8f981789ebf22756d518253ac7cf4f44b165add56ab28206c0fda7636fb0f98f1a66172130b7a057f4ccda7f4e202151b5430779a1c79a6a954e59be0b8691d9b20b576f5df8257e6214bf6d433de712b10ddda50c09f059c7bda06ddbf04fef33fc23260e67ea05ee8dd4fd69c78c6277616ed895bec587fec5816d93e4da28648533347fd0c30b755dd5a5f703996b2f2fd4da6378889123223b06976fd823dd9513c" + } + ] + }, + "policy": { + "flipcashUserId": "70c4a3df54af439a88fea7de606d04cb", + "cases": [ + { + "name": "dm-flag-on", + "isDirectMessage": true, + "useE2ee": true, + "peerUserId": "5f0e1d2c3b4a49688776a5b4c3d2e1f0", + "shouldEncrypt": true + }, + { + "name": "dm-flag-off", + "isDirectMessage": true, + "useE2ee": false, + "peerUserId": "5f0e1d2c3b4a49688776a5b4c3d2e1f0", + "shouldEncrypt": false + }, + { + "name": "non-dm-flag-on", + "isDirectMessage": false, + "useE2ee": true, + "peerUserId": "5f0e1d2c3b4a49688776a5b4c3d2e1f0", + "shouldEncrypt": false + }, + { + "name": "non-dm-flag-off", + "isDirectMessage": false, + "useE2ee": false, + "peerUserId": "5f0e1d2c3b4a49688776a5b4c3d2e1f0", + "shouldEncrypt": false + }, + { + "name": "flipcash-dm-flag-on", + "isDirectMessage": true, + "useE2ee": true, + "peerUserId": "70c4a3df54af439a88fea7de606d04cb", + "shouldEncrypt": false + }, + { + "name": "flipcash-dm-flag-off", + "isDirectMessage": true, + "useE2ee": false, + "peerUserId": "70c4a3df54af439a88fea7de606d04cb", + "shouldEncrypt": false + }, + { + "name": "flipcash-non-dm-flag-on", + "isDirectMessage": false, + "useE2ee": true, + "peerUserId": "70c4a3df54af439a88fea7de606d04cb", + "shouldEncrypt": false + }, + { + "name": "flipcash-prefix-15-bytes-dm-flag-on", + "isDirectMessage": true, + "useE2ee": true, + "peerUserId": "70c4a3df54af439a88fea7de606d04", + "shouldEncrypt": true + }, + { + "name": "flipcash-plus-1-byte-dm-flag-on", + "isDirectMessage": true, + "useE2ee": true, + "peerUserId": "70c4a3df54af439a88fea7de606d04cb00", + "shouldEncrypt": true + }, + { + "name": "empty-peer-dm-flag-on", + "isDirectMessage": true, + "useE2ee": true, + "peerUserId": "", + "shouldEncrypt": true + } + ] + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_media.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_media.json new file mode 100644 index 000000000..54f976f27 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_media.json @@ -0,0 +1,639 @@ +{ + "algorithm": "chat-media-photos", + "note": "Behavior fixture for docs/superpowers/specs/2026-09-28-chat-media-photos-design.md. Heights are points or dp as floats; compare with a 0.001 tolerance.", + "maxAttachments": 10, + "minAspect": 0.5, + "maxAspect": 2.0, + "jpegQualityLadder": [ + 0.9, + 0.8, + 0.7, + 0.6 + ], + "uploadMimeType": "image/jpeg", + "fanOut": [ + { + "name": "text-only", + "chips": [], + "text": "hello", + "replyTo": null, + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": null + } + ], + "note": "No chips: a text message, exactly as today." + }, + { + "name": "text-only-reply", + "chips": [], + "text": "hello", + "replyTo": "m1", + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": "m1" + } + ], + "note": "No chips, with a reply: a text reply, exactly as today." + }, + { + "name": "empty", + "chips": [], + "text": "", + "replyTo": null, + "messages": [], + "note": "Nothing to send. Send is disabled, so this pins that the builder agrees." + }, + { + "name": "one-photo", + "chips": [ + "a" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "One chip, no text: one media message with no caption." + }, + { + "name": "one-photo-caption", + "chips": [ + "a" + ], + "text": "look", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "look", + "text": null, + "replyTo": null + } + ], + "note": "One chip with text: the text is the caption." + }, + { + "name": "three-photos-caption", + "chips": [ + "a", + "b", + "c" + ], + "text": "from today", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "from today", + "text": null, + "replyTo": null + } + ], + "note": "The caption rides on the last message, so it lands under the last image." + }, + { + "name": "three-photos-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The reply attaches to the first message only." + }, + { + "name": "three-photos-caption-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "these?", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "these?", + "text": null, + "replyTo": null + } + ], + "note": "Reply on the first, caption on the last." + }, + { + "name": "one-photo-caption-reply", + "chips": [ + "a" + ], + "text": "this one", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "this one", + "text": null, + "replyTo": "m1" + } + ], + "note": "With one chip, the same message carries both reply and caption." + }, + { + "name": "ten-photos", + "chips": [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "d", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "e", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "f", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "g", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "h", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "i", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "j", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The composer maximum. Order is chip order." + } + ], + "bubble": [ + { + "name": "square", + "imageWidth": 1000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "1:1 fills the width, same height." + }, + { + "name": "landscape-4-3", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Landscape phone photo." + }, + { + "name": "portrait-3-4", + "imageWidth": 3024, + "imageHeight": 4032, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 320.0, + "cropped": false + }, + "note": "Portrait phone photo." + }, + { + "name": "wide-at-limit", + "imageWidth": 2000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": false + }, + "note": "Exactly 1:2 is not cropped." + }, + { + "name": "panorama", + "imageWidth": 8000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": true + }, + "note": "Wider than 1:2 clamps to 1:2 and crops." + }, + { + "name": "tall-at-limit", + "imageWidth": 1000, + "imageHeight": 2000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": false + }, + "note": "Exactly 2:1 is not cropped." + }, + { + "name": "screenshot", + "imageWidth": 1179, + "imageHeight": 2556, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": true + }, + "note": "An iPhone screenshot is taller than 2:1 and crops." + }, + { + "name": "tiny-upscaled", + "imageWidth": 40, + "imageHeight": 30, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Small images still take the full width. The bubble never shrinks to the image." + }, + { + "name": "missing-metadata", + "imageWidth": 0, + "imageHeight": 0, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "No dimensions (should not happen after READY): a square placeholder." + }, + { + "name": "android-dp", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 264.5, + "expected": { + "width": 264.5, + "height": 198.375, + "cropped": false + }, + "note": "Fractional widths are fine; compare with 0.001 tolerance." + } + ], + "downscale": [ + { + "name": "within-bounds", + "sourceWidth": 1200, + "sourceHeight": 900, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1200, + "height": 900 + }, + "note": "Already fits: unchanged." + }, + { + "name": "edge-bound-landscape", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1536 + }, + "note": "Long edge binds." + }, + { + "name": "edge-bound-portrait", + "sourceWidth": 3024, + "sourceHeight": 4032, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1536, + "height": 2048 + }, + "note": "Long edge binds, portrait." + }, + { + "name": "asymmetric-bounds", + "sourceWidth": 4000, + "sourceHeight": 4000, + "maxWidth": 3000, + "maxHeight": 1000, + "maxPixels": 0, + "expected": { + "width": 1000, + "height": 1000 + }, + "note": "The tighter of width and height binds." + }, + { + "name": "pixels-bind-first", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 4096, + "maxHeight": 4096, + "maxPixels": 4000000, + "expected": { + "width": 2309, + "height": 1732 + }, + "note": "Edges would allow the original; max_pixels binds instead." + }, + { + "name": "pixels-and-edges", + "sourceWidth": 8000, + "sourceHeight": 6000, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 2000000, + "expected": { + "width": 1632, + "height": 1224 + }, + "note": "max_pixels is tighter than the edge limit here." + }, + { + "name": "unbounded", + "sourceWidth": 5000, + "sourceHeight": 3000, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 0, + "expected": { + "width": 5000, + "height": 3000 + }, + "note": "No constraints: unchanged." + }, + { + "name": "never-upscale", + "sourceWidth": 300, + "sourceHeight": 200, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 16000000, + "expected": { + "width": 300, + "height": 200 + }, + "note": "Small source: unchanged." + }, + { + "name": "panorama-min-edge", + "sourceWidth": 20000, + "sourceHeight": 10, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1 + }, + "note": "An edge never goes below 1." + }, + { + "name": "odd-pixels", + "sourceWidth": 3001, + "sourceHeight": 2999, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 1000000, + "expected": { + "width": 1000, + "height": 999 + }, + "note": "Floor rounding still fits max_pixels." + } + ], + "constraintSelection": [ + { + "name": "exact-first", + "patterns": [ + "image/jpeg", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Exact match wins when listed first." + }, + { + "name": "wildcard-image", + "patterns": [ + "image/png", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "image/png does not match; image/* does." + }, + { + "name": "catch-all", + "patterns": [ + "video/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "Only */* matches." + }, + { + "name": "none", + "patterns": [ + "video/*", + "application/pdf" + ], + "mimeType": "image/jpeg", + "expectedIndex": null, + "note": "No entry: do not upload." + }, + { + "name": "policy-order-wins", + "patterns": [ + "*/*", + "image/jpeg" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Policy order is authoritative, even if a later entry is more specific." + } + ], + "strings": [ + { + "name": "no-caption", + "caption": null, + "expected": { + "snippet": "Photo", + "preview": "📷 Photo" + }, + "note": "No caption: the word Photo." + }, + { + "name": "caption", + "caption": "from today", + "expected": { + "snippet": "from today", + "preview": "📷 from today" + }, + "note": "The caption replaces the word Photo." + }, + { + "name": "emoji-caption", + "caption": "🎉 done", + "expected": { + "snippet": "🎉 done", + "preview": "📷 🎉 done" + }, + "note": "Captions are used as written." + } + ] +} 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..02359f029 --- /dev/null +++ b/FlipcashTests/Chat/AttachOverlayModeTests.swift @@ -0,0 +1,181 @@ +// +// 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, clears its top, 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 - BarMetrics.fieldPadding) + #expect(frame.minY < frame.maxY && frame.minY <= plus.maxY && frame.maxY >= plus.maxY) + let fieldTop = plus.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + #expect(frame.minY <= fieldTop - AttachOverlayLayout.fieldTopOverhang) + + 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..9a54eb6f4 --- /dev/null +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -0,0 +1,256 @@ +// +// 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: `+` is the + /// first control in the field's bottom row, past the compact bar's margin and the field's padding. + func pointAboveBar(by distance: CGFloat) -> CGPoint { + CGPoint(x: model.overKeyboard.plusFrame.midX, 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: point.x, 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 bar is one row tall: +, the text, and the controls beside it") + func barHeight_isOneRow() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let expected = BarMetrics.contentHeight + BarMetrics.contentPadding * 2 + #expect(abs(host.barHeight - expected) < 1, "Bar is \(host.barHeight) tall, expected \(expected)") + } + + @Test("The menu's leading edge is the composer field's, past the compact margin and the `$` beside it") + func menu_linesUpWithField() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let plus = host.model.overKeyboard.plusFrame + let menu = AttachSurfaceLayout.menuRect(plus: plus, size: CGSize(width: 280, height: 200), placement: .standsOnPlus) + let field = BarMetrics.compactInset + BarMetrics.contentHeight + ConversationBottomBar.leadingSpacing + #expect(abs(menu.minX - field) < 1, "Menu starts at \(menu.minX), the field at \(field)") + } + + @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..4634660f0 --- /dev/null +++ b/FlipcashTests/Chat/AttachPanelTests.swift @@ -0,0 +1,313 @@ +// +// AttachPanelTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import UIKit +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("A landing holds the staged photo from the hand-off until it has landed") + func landingHoldsImage() { + let model = ConversationBarModel() + model.attachCard.open(.camera, screenHeight: 874) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3)).image { _ in } + + model.attachCard.beginLanding(on: UUID(), image: image) + #expect(model.attachCard.landingImage === image) + model.attachCard.close() + #expect(model.attachCard.landingImage === image, "The surface still draws it as it shrinks") + + model.attachCard.endLanding() + #expect(model.attachCard.landingImage == nil) + } + + @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: plus.height / 2)) + #expect(AttachSurfaceLayout.collapsedCornerRadius(plus: plus) == 22) + 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 - BarMetrics.fieldPadding, y: 600 + BarMetrics.fieldPadding, width: 240, height: 144)) + #expect(standing.minX == plus.minX - BarMetrics.fieldPadding, "The menu lines up with the field's edge") + #expect(standing.maxY == plus.maxY + BarMetrics.fieldPadding, "The menu covers the field's bottom edge") + let straddling = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .straddlesPlus) + #expect(straddling == AttachOverlayLayout.panelFrame(plusFrame: plus, size: size)) + #expect(straddling.minX == plus.minX - BarMetrics.fieldPadding, "The over-keyboard menu lines up with the field's edge") + } + + @Test("With $ beside the field the menu moves out over it at the same width", arguments: [AttachMenuPlacement.standsOnPlus, .straddlesPlus]) + func menuReachesOverCash(placement: AttachMenuPlacement) { + let plus = CGRect(x: 60, y: 700, width: 34, height: 34) + let size = CGSize(width: 280, height: 144) + let aligned = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement) + let reaching = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement, leadingReach: 44) + #expect(reaching.minX == aligned.minX - 44) + #expect(reaching.width == aligned.width) + #expect(reaching.minY == aligned.minY && reaching.height == aligned.height) + } + + @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/ChatBubbleViewTests.swift b/FlipcashTests/Chat/ChatBubbleViewTests.swift index c0b078245..2baaa3324 100644 --- a/FlipcashTests/Chat/ChatBubbleViewTests.swift +++ b/FlipcashTests/Chat/ChatBubbleViewTests.swift @@ -355,7 +355,7 @@ struct ChatBubbleViewBareTests { let view = UIView() let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 40, height: 40)) BubbleBackgroundView.raise(view, shape: path) - #expect(view.layer.shadowOpacity == 0.65) + #expect(view.layer.shadowOpacity == 0.135) #expect(view.layer.shadowPath != nil) } 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..0cd036db9 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaCellTests.swift @@ -0,0 +1,309 @@ +// +// ChatMediaCellTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import FlipcashUI +import Kingfisher + +@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, + remote: remoteURL.map { ChatMediaLocation(url: $0) } + ) + return cell + } + + private func outgoing(id: String = "1", receipt: ChatReceipt? = nil) -> ChatMessage { + ChatMessage(id: id, content: .media(media()), sender: .me, receipt: receipt) + } + + // MARK: - Send progress - + + @Test("A row with no send progress draws no overlay") + func noProgressNoOverlay() { + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: nil, remote: nil) + #expect(!cell.progressOverlay.isShowing) + #expect(cell.progressOverlay.isHidden) + } + + @Test("An uploading photo shows its share of bytes sent; processing shows a full, indeterminate bar") + func overlayFollowsProgress() async { + let progress = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(cell.progressOverlay.isShowing) + #expect(cell.progressOverlay.fraction == 0) + #expect(!cell.progressOverlay.isIndeterminate) + + progress.didUpload(BlobUploadProgress(sentBytes: 40, totalBytes: 100)) + await waitFor { cell.progressOverlay.fraction == 0.4 } + #expect(cell.progressOverlay.fraction == 0.4) + + progress.beginProcessing() + await waitFor { cell.progressOverlay.isIndeterminate } + #expect(cell.progressOverlay.fraction == 1) + #expect(cell.progressOverlay.isIndeterminate) + #expect(cell.progressOverlay.isShowing) + } + + @Test("The overlay hides once the photo is sent") + func overlayHidesWhenSent() async { + let progress = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + progress.beginSending() + + progress.finish() + await waitFor { !cell.progressOverlay.isShowing } + #expect(!cell.progressOverlay.isShowing) + } + + @Test("The confirmed row, reconfigured without progress, hides the overlay") + func overlayHidesWhenConfirmedRowLosesProgress() { + let progress = ChatPhotoSendProgress() + progress.beginSending() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(cell.progressOverlay.isShowing) + + cell.configure(with: outgoing(receipt: .delivered), maxWidth: 240, localImage: nil, progress: nil, remote: nil) + #expect(!cell.progressOverlay.isShowing) + } + + @Test("A failed row hides the overlay for the failed receipt, and a retry brings it back") + func failedRowHidesOverlay() async { + let progress = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + + progress.fail() + cell.configure(with: outgoing(receipt: .failed("Not delivered")), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(!cell.progressOverlay.isShowing) + #expect(cell.progressOverlay.isHidden) + + progress.beginAttempt() + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(cell.progressOverlay.isShowing) + } + + @Test("A recycled cell drops the previous row's progress") + func recycledCellIgnoresOldProgress() async { + let old = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(id: "old"), maxWidth: 240, localImage: nil, progress: old, remote: nil) + cell.prepareForReuse() + cell.configure(with: outgoing(id: "new", receipt: .delivered), maxWidth: 240, localImage: nil, progress: nil, remote: nil) + + old.didUpload(BlobUploadProgress(sentBytes: 50, totalBytes: 100)) + for _ in 0..<5 { await Task.yield() } + #expect(!cell.progressOverlay.isShowing) + } + + private func waitFor(_ condition: () -> Bool) async { + for _ in 0..<50 where !condition() { await Task.yield() } + } + + @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.source(blobID: blobID, location: ChatMediaLocation(url: URL(string: "https://cdn.example.com/a.jpg?sig=1")!)) + let second = ChatMediaImageSource.source(blobID: blobID, location: ChatMediaLocation(url: URL(string: "https://cdn.example.com/a.jpg?sig=2")!)) + let other = ChatMediaImageSource.source(blobID: BlobID(data: Data([2])), location: ChatMediaLocation(url: URL(string: "https://cdn.example.com/a.jpg?sig=1")!)) + + #expect(first.cacheKey == second.cacheKey) + #expect(first.cacheKey != other.cacheKey) + #expect(second.url?.absoluteString.hasSuffix("sig=2") == true) + } + + @Test("An encrypted photo caches under the same blob key and loads through a decrypting provider") + func encryptedPhotoCachesUnderBlob() { + let blobID = BlobID(data: Data([1])) + let url = URL(string: "https://cdn.example.com/a.bin?sig=1")! + let plain = ChatMediaImageSource.source(blobID: blobID, location: ChatMediaLocation(url: url)) + let sealed = ChatMediaImageSource.source(blobID: blobID, location: ChatMediaLocation(url: url) { $0 }) + + #expect(sealed.cacheKey == plain.cacheKey) + guard case .provider = sealed else { + Issue.record("An encrypted photo must load through a provider, not straight from the network") + return + } + } + + @Test("A downloaded photo persists to disk, so it survives a memory trim or relaunch") + func persistsDownloadToDisk() async { + let key = "chat-media-test-\(UUID().uuidString)" + let processor = DownsamplingImageProcessor(size: CGSize(width: 40, height: 40)) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { _ in } + + await withCheckedContinuation { continuation in + ChatMediaImageSource.persist(image, cacheType: .none, forKey: key, processor: processor) { + continuation.resume() + } + } + + let cache = ChatMediaImageSource.cache + cache.clearMemoryCache() + #expect(cache.imageCachedType(forKey: key, processorIdentifier: processor.identifier) == .disk) + try? await cache.removeImage(forKey: key, processorIdentifier: processor.identifier) + } + + @Test("A photo already served from a cache is not written again") + func skipsCachedHits() async { + let key = "chat-media-test-\(UUID().uuidString)" + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { _ in } + + ChatMediaImageSource.persist(image, cacheType: .memory, forKey: key, processor: nil) + + #expect(!ChatMediaImageSource.cache.imageCachedType(forKey: key).cached) + } + + // MARK: - Encrypted - + + /// A cell drawing an encrypted photo whose blob is a local file and whose decryption is `decrypt`. + private func encryptedCell(decrypt: @escaping @Sendable (Data) throws -> Data) throws -> ChatMediaCell { + let file = FileManager.default.temporaryDirectory.appendingPathComponent("chat-media-\(UUID().uuidString)") + try Data([1, 2, 3]).write(to: file) + let media = ChatMediaContent( + blobID: BlobID(data: Data(UUID().uuidString.utf8)), + width: 100, + height: 100, + blurhash: Self.blurhash, + caption: nil, + isRedacted: false, + sealed: SealedBlob(senderID: UUID(), plaintextSize: 3) + ) + 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: [], canReact: true), + maxWidth: 240, + localImage: nil, + remote: ChatMediaLocation(url: file, decrypt: decrypt) + ) + return cell + } + + private func waitForUnavailable(_ cell: ChatMediaCell) async { + for _ in 0..<200 where cell.unavailableLabel.isHidden { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + @Test("An encrypted photo that fails to authenticate keeps its BlurHash, says it can't be shown, and takes no tap") + func undecryptablePhotoShowsUnavailable() async throws { + let cell = try encryptedCell { _ in throw BlobOpenFailure.authentication } + #expect(cell.unavailableLabel.isHidden) + + await waitForUnavailable(cell) + + #expect(!cell.unavailableLabel.isHidden) + #expect(!cell.imageTap.isEnabled) + #expect(cell.imageView.image != nil) + } + + @Test("An encrypted photo whose plaintext doesn't decode as an image says it can't be shown") + func undecodablePhotoShowsUnavailable() async throws { + let cell = try encryptedCell { $0 } + + await waitForUnavailable(cell) + + #expect(!cell.unavailableLabel.isHidden) + } + + @Test("Only a blob that will never open counts as undecryptable; a failed fetch stays retryable") + func undecryptableClassification() { + func providerError(_ underlying: any Error) -> KingfisherError { + .imageSettingError(reason: .dataProviderError(provider: RawImageDataProvider(data: Data(), cacheKey: "k"), error: underlying)) + } + #expect(ChatMediaImageSource.isUndecryptable(providerError(BlobOpenFailure.authentication))) + #expect(ChatMediaImageSource.isUndecryptable(providerError(BlobOpenFailure.length))) + #expect(ChatMediaImageSource.isUndecryptable(providerError(BlobOpenFailure.undecodable))) + #expect(!ChatMediaImageSource.isUndecryptable(providerError(URLError(.notConnectedToInternet)))) + } +} 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..bff8027e5 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaURLResolverTests.swift @@ -0,0 +1,185 @@ +// +// 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.location(for: photo(isRedacted: true), canReact: true) { _ in }?.url == nil) + #expect(resolver.location(for: photo(isRedacted: false), canReact: false) { _ in }?.url == 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.location(for: photo, canReact: false) { _ in }?.url == nil) + + let resolved = await withCheckedContinuation { continuation in + let immediate = resolver.location(for: photo, canReact: true) { continuation.resume(returning: $0.url) } + #expect(immediate == nil) + // A second dequeue while the first fetch is in flight does not start another. + #expect(resolver.location(for: photo, canReact: true) { _ in }?.url == nil) + } + #expect(resolved == url) + + #expect(resolver.location(for: photo, canReact: true) { _ in }?.url == 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.thumbnailLocation(for: .media(thumbnailBlobID: nil, sealed: nil), canReact: true) { _ in }?.url == nil) + #expect(resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: false) { _ in }?.url == nil) + #expect(resolver.thumbnailLocation(for: .text, canReact: true) { _ in }?.url == nil) + #expect(resolver.thumbnailLocation(for: .unavailable, canReact: true) { _ in }?.url == 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.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true) { + continuation.resume(returning: $0.url) + } + #expect(immediate == nil) + } + #expect(resolved == url) + + #expect(resolver.location(for: photo(isRedacted: false), canReact: true) { _ in }?.url == 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.thumbnailLocation(for: .media(thumbnailBlobID: nil, sealed: nil), canReact: true)?.url == nil) + #expect(spy.calls.isEmpty) + + #expect(await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true)?.url == url) + #expect(await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true)?.url == 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.location(for: photo, canReact: true) { row = $0.url } + #expect(immediate == nil) + _ = resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true) { quote in + continuation.resume(returning: (row, quote.url)) + } + } + #expect(row == url) + #expect(quote == url) + } + + @Test("An encrypted photo waits on the chat's decryption, fetches it once, and decrypts with its blob") + func encryptedPhotoDecrypts() async throws { + let url = url + let sealed = SealedBlob(senderID: UserID(), plaintextSize: 3) + let decryptCalls = FetchSpy() + let resolver = ChatMediaURLResolver( + fetch: { _ in url }, + decrypt: { + decryptCalls.calls.append(BlobID(data: Data())) + return { blob, blobID, sealed in Data(blob.reversed()) + blobID.data + Data([UInt8(sealed.plaintextSize)]) } + } + ) + let sealedPhoto = ChatMediaContent(blobID: blobID, width: 1, height: 1, blurhash: nil, caption: nil, isRedacted: false, sealed: sealed) + + let location = await withCheckedContinuation { continuation in + let immediate = resolver.location(for: sealedPhoto, canReact: true) { continuation.resume(returning: $0) } + #expect(immediate == nil) + } + #expect(location.url == url) + let decrypt = try #require(location.decrypt) + #expect(try decrypt(Data([1, 2])) == Data([2, 1, 1, 3])) + + let cached = try #require(resolver.location(for: sealedPhoto, canReact: true) { _ in }) + #expect(cached.decrypt != nil) + #expect(await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: sealed), canReact: true)?.decrypt != nil) + #expect(decryptCalls.calls.count == 1) + #expect(resolver.location(for: photo(isRedacted: false), canReact: true) { _ in }?.decrypt == nil) + } + + @Test("An encrypted photo stays on its BlurHash while the chat's key is unknown") + func encryptedPhotoWithoutKey() async { + let url = url + let resolver = ChatMediaURLResolver(fetch: { _ in url }, decrypt: { nil }) + let sealed = SealedBlob(senderID: UserID(), plaintextSize: 3) + let photo = ChatMediaContent(blobID: blobID, width: 1, height: 1, blurhash: nil, caption: nil, isRedacted: false, sealed: sealed) + + let location = await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: sealed), canReact: true) + #expect(location == nil) + #expect(resolver.location(for: photo, canReact: true) { _ in } == nil) + } +} diff --git a/FlipcashTests/Chat/ChatMediaUploaderTests.swift b/FlipcashTests/Chat/ChatMediaUploaderTests.swift new file mode 100644 index 000000000..ddff56d7c --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaUploaderTests.swift @@ -0,0 +1,389 @@ +// +// ChatMediaUploaderTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import SharedCore +@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) + }.blobID + + #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 photo = try await ChatMediaUploader(blob: blob, backoff: [.zero]).upload(Self.image(width: 40, height: 30)) { _, _ in } + + #expect(photo == .plain(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: - Encrypted - + + private static func seal() throws -> ChatSeal { + try ChatSeal( + cipher: DefaultChatCipher.shared, + owner: KeyPair.generate()!, + peerPublicKey: KeyPair.generate()!.publicKey, + conversationID: ConversationID(data: Data(repeating: 0x07, count: 32)), + selfUserID: UUID() + ) + } + + @Test("In a chat that encrypts, the photo is sealed for it within the encrypted ceiling, with its blurhash") + func encryptedChatSealsPhoto() async throws { + let policy = UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init(pattern: "image/*", maxSizeBytes: 5_000_000, image: .init(maxWidth: 4096, maxHeight: 4096, maxPixels: 0)), + ], encrypted: .init(maxSizeBytes: 2_000_000, image: .init(maxWidth: 1000, maxHeight: 1000, maxPixels: 0))) + let blob = MockChatMediaBlobStore(policy: policy) + let seal = try Self.seal() + var uploader = ChatMediaUploader(blob: blob) + uploader.seal = { seal } + + let photo = try await uploader.upload(Self.image(width: 3000, height: 2000)) { _, _ in } + + guard case .sealed(let sealed) = photo else { + Issue.record("Expected a sealed photo") + return + } + #expect(blob.storedData.isEmpty) + #expect(blob.encryptedStores.count == 1) + #expect(blob.encryptedStores.first?.conversationID == seal.conversationID) + #expect(sealed.width == 1000) + #expect((666...667).contains(sealed.height)) + #expect(sealed.mimeType == "image/jpeg") + #expect(sealed.sizeBytes == blob.encryptedStores.first?.image.count) + #expect(!sealed.blurhash.isEmpty && sealed.blurhash.count <= 64) + #expect(blob.finalizedBlobIDs == [sealed.blobID]) + } + + @Test("A chat that encrypts under a policy with no encrypted constraints uploads nothing, for good") + func encryptedChatWithoutPolicyUploadsNothing() async throws { + let blob = MockChatMediaBlobStore() + let seal = try Self.seal() + var uploader = ChatMediaUploader(blob: blob) + uploader.seal = { seal } + + let error = await Self.uploadError(uploader) + + #expect(error?.isRetryable == false) + #expect(blob.storeAttempts == 0) + } + + @Test("A missing peer key uploads nothing and stays retryable; it never falls back to plaintext") + func missingPeerKeyUploadsNothing() async { + let blob = MockChatMediaBlobStore() + var uploader = ChatMediaUploader(blob: blob) + uploader.seal = { throw PeerKeyUnavailable(userID: UUID()) } + + let error = await Self.uploadError(uploader) + + #expect(error?.isRetryable == true) + #expect(blob.storeAttempts == 0) + } + + // MARK: - Gate - + + @Test("Camera and Photos show in an encrypted DM, a plaintext chat, and not before the chat loads") + func gateShowsPhotosInEncryptedDMs() { + let encrypted = Conversation( + id: ConversationID(data: Data(repeating: 0x01, count: 32)), + members: [ConversationMember(userID: UUID(), displayName: "A"), ConversationMember(userID: UUID(), displayName: "B")], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: .contactDm, + useE2Ee: true + ) + #expect(E2eePolicy.shouldEncrypt(encrypted)) + #expect(ChatMediaGate.acceptsMedia(encrypted)) + + var plain = encrypted + plain.useE2Ee = false + #expect(ChatMediaGate.acceptsMedia(plain)) + #expect(!ChatMediaGate.acceptsMedia(nil)) + } + + // 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.blobID + + #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)) + } + + /// The bytes were stored before the wait failed, which is how a send interrupted by the app + /// going to the background fails; storing them again would leave an orphan blob. + @Test("Retrying a chip whose wait for the server failed waits again without storing again") + func retryingFailedChipResumesStoredBlob() 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.blobID + + #expect(chip.state == .uploaded(blobID)) + #expect(blob.storeAttempts == 1) + #expect(blob.finalizedBlobIDs == [blobID]) + } + + @Test("A chip's progress follows its bytes, then waits on the server's processing once stored") + func chipProgressReachesProcessing() async throws { + let blob = MockChatMediaBlobStore() + blob.progressReports = [ + BlobUploadProgress(sentBytes: 50, totalBytes: 100), + BlobUploadProgress(sentBytes: 100, totalBytes: 100), + ] + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: blob))) + #expect(chip.progress.phase == .preparing) + + _ = try await #require(chip.uploadTask).value + + #expect(chip.progress.phase == .processing) + #expect(chip.progress.showsOverlay) + } + + @Test("A chip whose upload fails hands its progress to the failed state; retrying starts it over") + func chipProgressFailsAndRetries() 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.progress.phase == .failed) + #expect(!chip.progress.showsOverlay) + + blob.finalization = .success(()) + chip.startUpload(using: uploader) + #expect(chip.progress.phase == .preparing) + _ = try await #require(chip.uploadTask).value + #expect(chip.progress.phase == .processing) + } + + @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) + } + + @Test("A photo goes out only under the encryption choice it was uploaded for") + func photoSendMatchesSeal() throws { + let seal = try Self.seal() + let chat = seal.conversationID + let other = ConversationID(data: Data(repeating: 0x08, count: 32)) + let blobID = BlobID(data: Data([1])) + let sealed = SealedPhoto(blobID: blobID, mimeType: "image/jpeg", sizeBytes: 10, width: 1, height: 1, blurhash: "00") + + #expect(EncryptedChatClient.photoSend(.plain(blobID), seal: nil, conversationID: chat) == .plain(blobID)) + #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: seal, conversationID: chat) == .sealed(sealed, seal)) + // The chat started encrypting after the photo uploaded in plaintext. + #expect(EncryptedChatClient.photoSend(.plain(blobID), seal: seal, conversationID: chat) == .mismatch) + // The chat stopped encrypting after the photo uploaded sealed. + #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: nil, conversationID: chat) == .mismatch) + // A seal for a different chat. + #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: seal, conversationID: other) == .mismatch) + } +} diff --git a/FlipcashTests/Chat/ChatMediaViewerTests.swift b/FlipcashTests/Chat/ChatMediaViewerTests.swift new file mode 100644 index 000000000..bc9cbcca7 --- /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, remote: remoteURL.map { ChatMediaLocation(url: $0) }, 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.remote?.url == 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, remote: 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, remote: ChatMediaLocation(url: 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/ChatPhotoSendProgressTests.swift b/FlipcashTests/Chat/ChatPhotoSendProgressTests.swift new file mode 100644 index 000000000..4281815e5 --- /dev/null +++ b/FlipcashTests/Chat/ChatPhotoSendProgressTests.swift @@ -0,0 +1,113 @@ +// +// ChatPhotoSendProgressTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo send progress") +@MainActor +struct ChatPhotoSendProgressTests { + + private func bytes(_ sent: Int64, of total: Int64) -> BlobUploadProgress { + BlobUploadProgress(sentBytes: sent, totalBytes: total) + } + + @Test("Bytes sent map to the share of the body, clamped, and nil for an unknown length") + func bytesToFraction() { + #expect(bytes(0, of: 200).fraction == 0) + #expect(bytes(50, of: 200).fraction == 0.25) + #expect(bytes(200, of: 200).fraction == 1) + #expect(bytes(300, of: 200).fraction == 1) + #expect(bytes(10, of: 0).fraction == nil) + #expect(bytes(10, of: -1).fraction == nil) + } + + @Test("A send runs preparing, uploading, processing, sending, sent") + func happyPath() { + let progress = ChatPhotoSendProgress() + #expect(progress.phase == .preparing) + #expect(progress.showsOverlay) + + progress.didUpload(bytes(25, of: 100)) + #expect(progress.phase == .uploading(fraction: 0.25)) + + progress.didUpload(bytes(100, of: 100)) + #expect(progress.phase == .uploading(fraction: 1)) + + progress.beginProcessing() + #expect(progress.phase == .processing) + #expect(progress.showsOverlay) + + progress.beginSending() + #expect(progress.phase == .sending) + #expect(progress.showsOverlay) + + progress.finish() + #expect(progress.phase == .sent) + #expect(!progress.showsOverlay) + } + + @Test("The bar never runs backwards within an attempt") + func monotonicWithinAttempt() { + let progress = ChatPhotoSendProgress() + progress.didUpload(bytes(60, of: 100)) + progress.didUpload(bytes(40, of: 100)) + #expect(progress.phase == .uploading(fraction: 0.6)) + } + + @Test("A byte count that lands after the upload moved on is ignored") + func lateBytesIgnored() { + let progress = ChatPhotoSendProgress() + progress.beginProcessing() + progress.didUpload(bytes(50, of: 100)) + #expect(progress.phase == .processing) + + progress.fail() + progress.didUpload(bytes(50, of: 100)) + #expect(progress.phase == .failed) + } + + @Test("A byte count with no known length leaves the phase alone") + func unknownLengthIgnored() { + let progress = ChatPhotoSendProgress() + progress.didUpload(bytes(50, of: 0)) + #expect(progress.phase == .preparing) + } + + @Test("A failure hides the overlay; a new attempt brings it back from the start", arguments: [ + ChatPhotoSendProgress.Phase.preparing, .uploading(fraction: 0.5), .processing, .sending, + ]) + func failureThenRetry(from phase: ChatPhotoSendProgress.Phase) { + let progress = ChatPhotoSendProgress() + switch phase { + case .preparing: break + case .uploading(let fraction): progress.didUpload(bytes(Int64(fraction * 100), of: 100)) + case .processing: progress.beginProcessing() + case .sending: progress.beginSending() + case .sent, .failed: Issue.record("not a starting phase") + } + #expect(progress.phase == phase) + + progress.fail() + #expect(progress.phase == .failed) + #expect(!progress.showsOverlay) + + progress.beginAttempt() + #expect(progress.phase == .preparing) + #expect(progress.showsOverlay) + } + + @Test("A retried store starts the bar over") + func retriedStoreResets() { + let progress = ChatPhotoSendProgress() + progress.didUpload(bytes(80, of: 100)) + progress.beginAttempt() + progress.didUpload(bytes(10, of: 100)) + #expect(progress.phase == .uploading(fraction: 0.1)) + } +} 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..8785bb956 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])), sealed: nil)) + let redacted = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: nil, sealed: 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])), sealed: nil)) + 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/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index b8379cf2a..9f6471cb9 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -106,6 +106,24 @@ struct ChatScreenBarClipTests { #expect(clipHeight(bar, window) == composerRow + secondLine) } + @Test("A composer shrinking holds the clip until its spring is done, and reserves the new room at once") + func composerShrink_holdsTheClip() async throws { + let (screen, bar, window) = makeScreen() + let stacked = composerRow + 48 + + report(screen, composerRow) + report(screen, stacked) + let transcript = try #require(screen.view.firstSubview(of: UICollectionView.self)) + let insetBefore = transcript.contentInset.bottom + + report(screen, composerRow) + #expect(transcript.contentInset.bottom == insetBefore - 48) + #expect(clipHeight(bar, window) == stacked, "The clip keeps the text and caret uncovered on the way down") + + try await Task.sleep(for: .seconds(ChatMotion.replySurface.duration + 0.2)) + #expect(clipHeight(bar, window) == composerRow) + } + @Test("The mention list stacks over an open reply and the clip reaches the whole bar") func mentionsOverReply_clipReachesTheWholeBar() { let (screen, bar, window) = makeScreen() @@ -184,3 +202,12 @@ struct ChatScreenBarClipTests { #expect(rooms.last.map { $0 - strip } == withStrip) } } + +private extension UIView { + func firstSubview(of type: T.Type) -> T? { + for subview in subviews { + if let match = subview as? T ?? subview.firstSubview(of: type) { return match } + } + return nil + } +} 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..724dcf2a3 --- /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 only its thumbnail; the sent bubble shows progress", arguments: [ + ComposerChip.State.preparing, + .uploading, + ]) + func inFlightShowsNothing(state: ComposerChip.State) { + #expect(ComposerChipBadge(state) == .none) + } + + @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) + } + + @Test("Before the chat exists, Send Cash stays the full-width call to action") + func callToActionBeforeChatExists() { + #expect(control(chatExists: false) == .sendCash) + } + + @Test("Before the chat exists with nothing to send, nothing stands outside the field") + func emptyBeforeChatExistsWithoutCash() { + #expect(control(chatExists: false, showsSendCash: false) == .none) + } + + @Test("Once the chat exists, Send Cash is the round `$` beside the field") + func cashOnceChatExists() { + #expect(control() == .cash) + } + + @Test("Once the chat exists with nothing to send, nothing stands beside the field") + func noneOnceChatExistsWithoutCash() { + #expect(control(showsSendCash: false) == .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) + } +} + +@MainActor +@Suite("Conversation bar bottom row") +struct ConversationBarBottomRowTests { + + private func row( + isEditing: Bool = false, + chatExists: Bool = true, + acceptsMedia: Bool = true, + attachedCount: Int = 0 + ) -> ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: isEditing, + chatExists: chatExists, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) + } + + @Test("Where the chat takes media, the row holds `+` with the camera and photos") + func plus() { + let row = row() + #expect(row.plusItems == [.camera, .photos]) + #expect(!row.isEmpty) + } + + @Test("Cash is never a menu row: the `$` beside the field takes it") + func cashLeavesTheMenu() { + #expect(!row().plusItems.contains(.cash)) + #expect(!row(acceptsMedia: false).plusItems.contains(.cash)) + } + + @Test("With no menu rows left, `+` goes", arguments: [false, true]) + func noPlus(isFull: Bool) { + let row = row(acceptsMedia: isFull, attachedCount: isFull ? ComposerModel.maxAttachments : 0) + #expect(row.plusItems.isEmpty) + #expect(row.isEmpty) + } + + @Test("An edit hides the bottom row") + func hiddenWhileEditing() { + #expect(row(isEditing: true).isEmpty) + } + + @Test("Before the chat exists there is no bottom row") + func hiddenBeforeChatExists() { + #expect(row(chatExists: false).isEmpty) + } +} diff --git a/FlipcashTests/Chat/ConversationMediaSendTests.swift b/FlipcashTests/Chat/ConversationMediaSendTests.swift new file mode 100644 index 000000000..ee5507031 --- /dev/null +++ b/FlipcashTests/Chat/ConversationMediaSendTests.swift @@ -0,0 +1,344 @@ +// +// 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 { .plain(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 { .plain(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) + #expect(rejected.progress.phase == .failed) + } + + @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) + + let chip = uploadedChip("a") + #expect(!(await controller.sendMedia([chip], caption: "hi", to: conversationID))) + + #expect(pendingMedia(controller).map(\.status) == [.failed]) + #expect(chip.progress.phase == .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 kept once it is confirmed") + func localImageOutlivesConfirm() 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 + // The sent row keeps the client id, so its bubble keeps drawing the photo instead of the BlurHash. + #expect(controller.pendingMediaImage(forMessageID: messageID) === chip.image) + } + + @Test("The send progress is served for a pending photo, ends sent, and is dropped once confirmed") + func progressLivesUntilConfirmed() 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.pendingMediaProgress(forMessageID: messageID) === chip.progress) + + gate.open(.success(blobID("a"))) + #expect(await send.value) + #expect(chip.progress.phase == .sent) + #expect(controller.pendingMediaProgress(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)) + #expect(chip.progress.phase == .failed) + 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) + #expect(chip.progress.phase == .sent, "the retried upload brings the overlay back and ends it sent") + } + + @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/Fixtures/chat_media.json b/FlipcashTests/Chat/Fixtures/chat_media.json new file mode 100644 index 000000000..54f976f27 --- /dev/null +++ b/FlipcashTests/Chat/Fixtures/chat_media.json @@ -0,0 +1,639 @@ +{ + "algorithm": "chat-media-photos", + "note": "Behavior fixture for docs/superpowers/specs/2026-09-28-chat-media-photos-design.md. Heights are points or dp as floats; compare with a 0.001 tolerance.", + "maxAttachments": 10, + "minAspect": 0.5, + "maxAspect": 2.0, + "jpegQualityLadder": [ + 0.9, + 0.8, + 0.7, + 0.6 + ], + "uploadMimeType": "image/jpeg", + "fanOut": [ + { + "name": "text-only", + "chips": [], + "text": "hello", + "replyTo": null, + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": null + } + ], + "note": "No chips: a text message, exactly as today." + }, + { + "name": "text-only-reply", + "chips": [], + "text": "hello", + "replyTo": "m1", + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": "m1" + } + ], + "note": "No chips, with a reply: a text reply, exactly as today." + }, + { + "name": "empty", + "chips": [], + "text": "", + "replyTo": null, + "messages": [], + "note": "Nothing to send. Send is disabled, so this pins that the builder agrees." + }, + { + "name": "one-photo", + "chips": [ + "a" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "One chip, no text: one media message with no caption." + }, + { + "name": "one-photo-caption", + "chips": [ + "a" + ], + "text": "look", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "look", + "text": null, + "replyTo": null + } + ], + "note": "One chip with text: the text is the caption." + }, + { + "name": "three-photos-caption", + "chips": [ + "a", + "b", + "c" + ], + "text": "from today", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "from today", + "text": null, + "replyTo": null + } + ], + "note": "The caption rides on the last message, so it lands under the last image." + }, + { + "name": "three-photos-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The reply attaches to the first message only." + }, + { + "name": "three-photos-caption-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "these?", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "these?", + "text": null, + "replyTo": null + } + ], + "note": "Reply on the first, caption on the last." + }, + { + "name": "one-photo-caption-reply", + "chips": [ + "a" + ], + "text": "this one", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "this one", + "text": null, + "replyTo": "m1" + } + ], + "note": "With one chip, the same message carries both reply and caption." + }, + { + "name": "ten-photos", + "chips": [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "d", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "e", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "f", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "g", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "h", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "i", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "j", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The composer maximum. Order is chip order." + } + ], + "bubble": [ + { + "name": "square", + "imageWidth": 1000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "1:1 fills the width, same height." + }, + { + "name": "landscape-4-3", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Landscape phone photo." + }, + { + "name": "portrait-3-4", + "imageWidth": 3024, + "imageHeight": 4032, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 320.0, + "cropped": false + }, + "note": "Portrait phone photo." + }, + { + "name": "wide-at-limit", + "imageWidth": 2000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": false + }, + "note": "Exactly 1:2 is not cropped." + }, + { + "name": "panorama", + "imageWidth": 8000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": true + }, + "note": "Wider than 1:2 clamps to 1:2 and crops." + }, + { + "name": "tall-at-limit", + "imageWidth": 1000, + "imageHeight": 2000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": false + }, + "note": "Exactly 2:1 is not cropped." + }, + { + "name": "screenshot", + "imageWidth": 1179, + "imageHeight": 2556, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": true + }, + "note": "An iPhone screenshot is taller than 2:1 and crops." + }, + { + "name": "tiny-upscaled", + "imageWidth": 40, + "imageHeight": 30, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Small images still take the full width. The bubble never shrinks to the image." + }, + { + "name": "missing-metadata", + "imageWidth": 0, + "imageHeight": 0, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "No dimensions (should not happen after READY): a square placeholder." + }, + { + "name": "android-dp", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 264.5, + "expected": { + "width": 264.5, + "height": 198.375, + "cropped": false + }, + "note": "Fractional widths are fine; compare with 0.001 tolerance." + } + ], + "downscale": [ + { + "name": "within-bounds", + "sourceWidth": 1200, + "sourceHeight": 900, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1200, + "height": 900 + }, + "note": "Already fits: unchanged." + }, + { + "name": "edge-bound-landscape", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1536 + }, + "note": "Long edge binds." + }, + { + "name": "edge-bound-portrait", + "sourceWidth": 3024, + "sourceHeight": 4032, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1536, + "height": 2048 + }, + "note": "Long edge binds, portrait." + }, + { + "name": "asymmetric-bounds", + "sourceWidth": 4000, + "sourceHeight": 4000, + "maxWidth": 3000, + "maxHeight": 1000, + "maxPixels": 0, + "expected": { + "width": 1000, + "height": 1000 + }, + "note": "The tighter of width and height binds." + }, + { + "name": "pixels-bind-first", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 4096, + "maxHeight": 4096, + "maxPixels": 4000000, + "expected": { + "width": 2309, + "height": 1732 + }, + "note": "Edges would allow the original; max_pixels binds instead." + }, + { + "name": "pixels-and-edges", + "sourceWidth": 8000, + "sourceHeight": 6000, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 2000000, + "expected": { + "width": 1632, + "height": 1224 + }, + "note": "max_pixels is tighter than the edge limit here." + }, + { + "name": "unbounded", + "sourceWidth": 5000, + "sourceHeight": 3000, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 0, + "expected": { + "width": 5000, + "height": 3000 + }, + "note": "No constraints: unchanged." + }, + { + "name": "never-upscale", + "sourceWidth": 300, + "sourceHeight": 200, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 16000000, + "expected": { + "width": 300, + "height": 200 + }, + "note": "Small source: unchanged." + }, + { + "name": "panorama-min-edge", + "sourceWidth": 20000, + "sourceHeight": 10, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1 + }, + "note": "An edge never goes below 1." + }, + { + "name": "odd-pixels", + "sourceWidth": 3001, + "sourceHeight": 2999, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 1000000, + "expected": { + "width": 1000, + "height": 999 + }, + "note": "Floor rounding still fits max_pixels." + } + ], + "constraintSelection": [ + { + "name": "exact-first", + "patterns": [ + "image/jpeg", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Exact match wins when listed first." + }, + { + "name": "wildcard-image", + "patterns": [ + "image/png", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "image/png does not match; image/* does." + }, + { + "name": "catch-all", + "patterns": [ + "video/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "Only */* matches." + }, + { + "name": "none", + "patterns": [ + "video/*", + "application/pdf" + ], + "mimeType": "image/jpeg", + "expectedIndex": null, + "note": "No entry: do not upload." + }, + { + "name": "policy-order-wins", + "patterns": [ + "*/*", + "image/jpeg" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Policy order is authoritative, even if a later entry is more specific." + } + ], + "strings": [ + { + "name": "no-caption", + "caption": null, + "expected": { + "snippet": "Photo", + "preview": "📷 Photo" + }, + "note": "No caption: the word Photo." + }, + { + "name": "caption", + "caption": "from today", + "expected": { + "snippet": "from today", + "preview": "📷 from today" + }, + "note": "The caption replaces the word Photo." + }, + { + "name": "emoji-caption", + "caption": "🎉 done", + "expected": { + "snippet": "🎉 done", + "preview": "📷 🎉 done" + }, + "note": "Captions are used as written." + } + ] +} diff --git a/FlipcashTests/Chat/PendingMediaPersistenceTests.swift b/FlipcashTests/Chat/PendingMediaPersistenceTests.swift new file mode 100644 index 000000000..239092c73 --- /dev/null +++ b/FlipcashTests/Chat/PendingMediaPersistenceTests.swift @@ -0,0 +1,281 @@ +// +// PendingMediaPersistenceTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import UIKit +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@MainActor +@Suite("Pending photo persistence") +struct PendingMediaPersistenceTests { + + private let owner = try! PublicKey(Data(repeating: 7, count: 32)) + private let conversationID = ConversationID.test(1) + private let selfUserID = UUID() + + private func makeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("pending-media-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func jpeg() -> Data { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 8, height: 6), format: format).jpegData(withCompressionQuality: 0.8) { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 8, height: 6)) + } + } + + private func blobID(_ name: String) -> BlobID { BlobID(data: Data(name.utf8)) } + + private func entry(stored: UploadedPhoto? = nil, caption: String? = nil, createdAt: Date = .now) -> PendingMediaStore.Entry { + .init(clientMessageID: UUID(), conversationID: conversationID, createdAt: createdAt, caption: caption, replyTo: MessageID(value: 9), stored: stored) + } + + private func makeController( + _ mock: MockConversations, + database: Database, + store: PendingMediaStore, + blob: MockChatMediaBlobStore + ) -> ConversationController { + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: selfUserID, + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + controller.pendingMedia = store + controller.restoredPhotoUploader = { _ in ChatMediaUploader(blob: blob) } + return controller + } + + private func pendingRows(_ controller: ConversationController) -> [ConversationMessage] { + controller.messages(for: conversationID).filter { $0.clientMessageID != nil && $0.id == .unassigned } + } + + private func yield(until condition: () -> Bool) async { + for _ in 0..<500 where !condition() { + await Task.yield() + } + } + + // MARK: - Store + + @Test("Entries, their photo and their stored state survive a new store over the same directory") + func roundTrips() throws { + let directory = try makeDirectory() + let store = PendingMediaStore(directory: directory, owner: owner) + let plain = entry(stored: .plain(blobID("a")), caption: "hi") + let sealed = entry(stored: .sealed(SealedPhoto(blobID: blobID("b"), mimeType: "image/jpeg", sizeBytes: 10, width: 8, height: 6, blurhash: "LEHV6n"))) + let bare = entry() + for item in [plain, sealed, bare] { + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + } + store.setStored(.plain(blobID("c")), for: bare.clientMessageID) + + let reloaded = PendingMediaStore(directory: directory, owner: owner) + + #expect(reloaded.entries.map(\.clientMessageID) == [plain, sealed, bare].map(\.clientMessageID)) + #expect(reloaded.entries[0].caption == "hi") + #expect(reloaded.entries[0].replyTo == MessageID(value: 9)) + #expect(reloaded.entries[0].chatID == conversationID) + #expect(reloaded.entries[1].stored == sealed.stored) + #expect(reloaded.entries[2].stored == .plain(blobID("c"))) + #expect(reloaded.imageData(for: reloaded.entries[0]) == store.imageData(for: plain)) + } + + @Test("Another owner's store does not see the entries") + func ownerScoped() throws { + let directory = try makeDirectory() + let store = PendingMediaStore(directory: directory, owner: owner) + store.add(entry()) + + let other = PendingMediaStore(directory: directory, owner: try PublicKey(Data(repeating: 8, count: 32))) + + #expect(other.entries.isEmpty) + } + + @Test("Removing an entry deletes its photo") + func removeDeletesFile() throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry() + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + + store.remove(clientMessageID: item.clientMessageID) + + #expect(store.entries.isEmpty) + #expect(store.imageData(for: item) == nil) + } + + @Test("Sweeping drops an entry with no photo and a photo with no entry") + func sweepsOrphans() throws { + let directory = try makeDirectory() + let store = PendingMediaStore(directory: directory, owner: owner) + let kept = entry() + let noFile = entry() + store.add(kept) + store.writeImage(jpeg(), for: kept.clientMessageID) + store.add(noFile) + let orphan = directory + .appendingPathComponent("flipcash-\(owner.base58)-pending-media") + .appendingPathComponent("\(UUID().uuidString).jpg") + try jpeg().write(to: orphan) + + store.sweepOrphans() + + #expect(store.entries.map(\.clientMessageID) == [kept.clientMessageID]) + #expect(!FileManager.default.fileExists(atPath: orphan.path)) + #expect(PendingMediaStore(directory: directory, owner: owner).entries.count == 1) + } + + @Test("A manifest that cannot be read is an empty store") + func unreadableManifestIsEmpty() throws { + let directory = try makeDirectory() + try Data("not json".utf8).write(to: directory.appendingPathComponent("flipcash-\(owner.base58)-pending-media.json")) + + #expect(PendingMediaStore(directory: directory, owner: owner).entries.isEmpty) + } + + // MARK: - Restore + + @Test("A send whose bytes never landed comes back as a failed, retryable row") + func unstoredEntryRestoresFailed() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry(caption: "hi") + store.add(item) + let bytes = jpeg() + store.writeImage(bytes, for: item.clientMessageID) + let mock = MockConversations() + let blob = MockChatMediaBlobStore() + let controller = makeController(mock, database: try Database.makeTemp().database, store: store, blob: blob) + + controller.restorePendingMedia() + + let rows = pendingRows(controller) + #expect(rows.map(\.status) == [.failed]) + #expect(rows.first?.clientMessageID == item.clientMessageID) + if case .media(_, let caption) = try #require(rows.first).content { + #expect(caption == "hi") + } else { + Issue.record("expected a media row") + } + #expect(rows.first?.repliedTo == MessageID(value: 9)) + #expect(controller.pendingMediaImage(forMessageID: try #require(rows.first?.stableID)) != nil) + #expect(blob.storeAttempts == 0) + + // Retrying uploads the held bytes as they are. + await controller.retry(clientMessageID: item.clientMessageID, in: conversationID) + + #expect(blob.storedData == [bytes]) + #expect(mock.sentMedia.count == 1) + #expect(mock.sentMedia.first?.clientMessageID == item.clientMessageID) + #expect(pendingRows(controller).isEmpty) + #expect(store.entries.isEmpty, "confirmed sends leave the store") + } + + @Test("A send whose bytes had landed resumes and posts without storing again") + func storedEntryResumes() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry(stored: .plain(blobID("landed"))) + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + let mock = MockConversations() + let blob = MockChatMediaBlobStore() + let controller = makeController(mock, database: try Database.makeTemp().database, store: store, blob: blob) + + controller.restorePendingMedia() + #expect(pendingRows(controller).map(\.status) == [.sending]) + await yield { !mock.sentMedia.isEmpty && store.entries.isEmpty } + + #expect(mock.sentMedia.map(\.blobID) == [blobID("landed")]) + #expect(blob.storeAttempts == 0) + #expect(blob.finalizedBlobIDs == [blobID("landed")]) + #expect(store.entries.isEmpty) + #expect(pendingRows(controller).isEmpty) + } + + @Test("An entry whose blob is already in a message this user sent is dropped; one whose blob is not is kept") + func reconcilesAgainstLoadedMessages() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let sent = entry(stored: .plain(blobID("sent"))) + let unsent = entry(stored: .plain(blobID("unsent"))) + for item in [sent, unsent] { + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + } + let database = try Database.makeTemp().database + let message = ConversationMessage( + id: MessageID(value: 5), + senderID: selfUserID, + content: .media([MediaAttachment(blobID: blobID("sent"), width: 8, height: 6, blurhash: nil)], caption: nil), + date: .now, + unreadSeq: 0, + repliedTo: nil, + status: .sent, + clientMessageID: nil + ) + try database.upsertConversationMessages([message], conversationID: conversationID) + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let controller = makeController(mock, database: database, store: store, blob: MockChatMediaBlobStore()) + + controller.restorePendingMedia() + await yield { pendingRows(controller).first?.status == .failed } + + #expect(store.entries.map(\.clientMessageID) == [unsent.clientMessageID]) + #expect(pendingRows(controller).map(\.clientMessageID) == [unsent.clientMessageID]) + #expect(mock.sentMedia.map(\.blobID) == [blobID("unsent")], "the already-sent photo is never posted again") + } + + @Test("A photo the server refuses for good leaves the store") + func rejectedUploadLeavesStore() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry(stored: .plain(blobID("bad"))) + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + let controller = makeController(MockConversations(), database: try Database.makeTemp().database, store: store, blob: blob) + + controller.restorePendingMedia() + await yield { store.entries.isEmpty } + + #expect(store.entries.isEmpty) + #expect(pendingRows(controller).map(\.status) == [.failed]) + } + + @Test("A photo sent through the controller is held until its post is confirmed") + func sendKeepsEntryUntilConfirmed() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let blob = MockChatMediaBlobStore() + let controller = makeController(mock, database: try Database.makeTemp().database, store: store, blob: blob) + let chip = ComposerChip(image: UIImage(data: jpeg())!) + chip.startUpload(using: ChatMediaUploader(blob: blob)) + + #expect(!(await controller.sendMedia([chip], caption: "hi", to: conversationID))) + + let held = try #require(store.entries.first) + #expect(held.caption == "hi") + #expect(held.stored == .plain(MockChatMediaBlobStore.blobID)) + #expect(store.imageData(for: held) == blob.storedData.first) + + mock.mediaSendError = nil + await controller.retry(clientMessageID: held.clientMessageID, in: conversationID) + + #expect(store.entries.isEmpty) + } +} 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 59a858961..8e6008901 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -1609,6 +1609,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/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 5834ce577..76593f653 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -103,7 +103,7 @@ struct DatabaseConversationsTests { switch loadedMessage.content { case .cash(let loadedExchanged): #expect(loadedExchanged.nativeAmount.value == amount) - case .text, .deleted, .encrypted, .widget: + case .text, .deleted, .encrypted, .widget, .media: Issue.record("Expected cash message content") } } @@ -478,6 +478,69 @@ struct DatabaseConversationsTests { #expect(ciphertext == Data([0x01, 0x02, 0x03, 0x04])) } + @Test("A media message round-trips its attachment, caption, and reply target") + func mediaMessageRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let attachment = MediaAttachment(blobID: BlobID(data: Data([9, 8, 7])), width: 300, height: 400, blurhash: "LEHV6nWB2yk8") + let captioned = ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([attachment], caption: "hi"), + date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 3, + repliedTo: MessageID(value: 7) + ) + let bare = ConversationMessage( + id: MessageID(value: 2), senderID: selfID, + content: .media([MediaAttachment(blobID: BlobID(data: Data([1])), width: 1, height: 2, blurhash: nil)], caption: nil), + date: Date(timeIntervalSince1970: 20), unreadSeq: 2, eventSequence: 4 + ) + + try database.upsertConversationMessages([captioned, bare], conversationID: id) + + #expect(try database.getConversationMessages(conversationID: id) == [captioned, bare]) + } + + @Test("A decrypted photo reloads with what it needs to decrypt its blob again") + func sealedMediaRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let attachment = MediaAttachment( + blobID: BlobID(data: Data(repeating: 9, count: 16)), width: 300, height: 400, blurhash: "LEHV6nWB2yk8", + sealed: SealedBlob(senderID: otherID, plaintextSize: 4321) + ) + let message = ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([attachment], caption: "secret"), + date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 3, + sealed: ConversationMessage.Sealed(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + ) + + try database.upsertConversationMessages([message], conversationID: id) + + #expect(try database.getConversationMessages(conversationID: id) == [message]) + } + + @Test("A redacted media message reloads still redacted, so its bytes stay unfetched after a relaunch") + func redactedMediaRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let message = ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([MediaAttachment(blobID: BlobID(data: Data([9])), width: 300, height: 400, blurhash: "LEHV6nWB2yk8")], caption: nil), + date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 3, + redacted: true + ) + + try database.upsertConversationMessages([message], conversationID: id) + + let loaded = try #require(try database.getConversationMessages(conversationID: id).first) + #expect(loaded == message) + #expect(loaded.redacted) + } + @Test("the catch-up cursor round-trips and survives a feed replace") func catchupCursorRoundTrip() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift new file mode 100644 index 000000000..16f2deac8 --- /dev/null +++ b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift @@ -0,0 +1,68 @@ +// +// 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)? + /// Reported through each store call's `onProgress` before it ends. + var progressReports: [BlobUploadProgress] = [] + + private(set) var storeAttempts = 0 + private(set) var storedData: [Data] = [] + private(set) var storedMimeTypes: [String] = [] + private(set) var finalizedBlobIDs: [BlobID] = [] + /// The plaintext images `storeEncryptedBlob` was handed, with the chat each was sealed for. + private(set) var encryptedStores: [(image: Data, conversationID: ConversationID)] = [] + + 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, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID { + storeAttempts += 1 + progressReports.forEach(onProgress) + onStore?() + let result = storeResults.isEmpty ? .success(Self.blobID) : storeResults.removeFirst() + let blobID = try result.get() + storedData.append(data) + storedMimeTypes.append(mimeType) + return blobID + } + + func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload { + storeAttempts += 1 + progressReports.forEach(onProgress) + onStore?() + let result = storeResults.isEmpty ? .success(Self.blobID) : storeResults.removeFirst() + let blobID = try result.get() + encryptedStores.append((image, seal.conversationID)) + return EncryptedBlobUpload(blobID: blobID, plaintextSize: image.count) + } + + 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 fa1e3ec67..0dfbcd2a5 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -16,6 +16,16 @@ 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 + /// The sealed metadata the photo went out with, nil for a plaintext photo. + var sealed: SealedPhoto? = nil + } 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 +74,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 +176,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. @@ -392,6 +411,33 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con ) } + func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? { + nil + } + + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? { + nil + } + + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + let blobID = photo.blobID + let sealed: SealedPhoto? + switch photo { + case .plain: sealed = nil + case .sealed(let photo): sealed = photo + } + let (count, error) = lock.withLock { + _sentMedia.append(SentMedia(conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID, sealed: sealed)) + 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/BarAccessories.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift index 3e0873de5..bb6f31551 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift @@ -8,7 +8,7 @@ import CoreGraphics /// The cards stacked above the composer row, as the bar reports them alongside its height. -public struct BarAccessories: Equatable, Sendable { +public nonisolated struct BarAccessories: Equatable, Sendable { /// A card that can stand above the composer row. public enum Kind: Hashable, Sendable { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index b238004c5..265dfc244 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; `quoteThumbnail` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnail: ChatMediaLocation? = 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, thumbnail: quoteThumbnail) 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..e25f43e95 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. + /// - quoteThumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + authorImageData: Data? = nil, + quoteThumbnail: ChatMediaLocation? = nil + ) { bubbleMaxWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.isActive = Self.cardFillsWidth(message.linkPreview?.card) - bubble.configure(with: message) + bubble.configure(with: message, quoteThumbnail: quoteThumbnail) 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..2a35e76e4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift @@ -0,0 +1,300 @@ +// +// 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 + /// The progress capsule's inset from the photo's bottom-right corner. + private static let progressInset: CGFloat = 10 + + private let stack = UIStackView() + private let imageBubble = BubbleBackgroundView() + let imageView = UIImageView() + /// Shown over the BlurHash when an encrypted photo's bytes fail to decrypt or check out. + let unavailableLabel = UILabel() + /// Shows how far an outgoing photo's send has got, until it is sent or fails. + let progressOverlay = ChatPhotoProgressOverlay() + 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" + unavailableLabel.text = ChatMediaStrings.undecryptable + unavailableLabel.font = .default(size: 14, weight: .medium) + unavailableLabel.textColor = .white + unavailableLabel.textAlignment = .center + unavailableLabel.numberOfLines = 0 + unavailableLabel.isHidden = true + unavailableLabel.translatesAutoresizingMaskIntoConstraints = false + imageBubble.addSubview(unavailableLabel) + progressOverlay.translatesAutoresizingMaskIntoConstraints = false + imageBubble.addSubview(progressOverlay) + 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), + + progressOverlay.widthAnchor.constraint(equalToConstant: ChatPhotoProgressOverlay.size.width), + progressOverlay.heightAnchor.constraint(equalToConstant: ChatPhotoProgressOverlay.size.height), + progressOverlay.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor, constant: -Self.progressInset), + progressOverlay.bottomAnchor.constraint(equalTo: imageBubble.bottomAnchor, constant: -Self.progressInset), + + unavailableLabel.centerYAnchor.constraint(equalTo: imageBubble.centerYAnchor), + unavailableLabel.leadingAnchor.constraint(equalTo: imageBubble.leadingAnchor, constant: Self.captionInset), + unavailableLabel.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor, constant: -Self.captionInset), + + 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 + showUnavailable(false) + progressOverlay.bind(nil, suppressed: false, animated: false) + 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. + /// - progress: the send progress of a pending photo this device staged, or nil. + /// - remote: where the photo downloads from, or nil until it is resolved. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + localImage: UIImage?, + progress: ChatPhotoSendProgress? = nil, + remote: ChatMediaLocation?, + 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 + // The same row losing its progress is a confirmed send, which fades; a new row starts clean. + progressOverlay.bind(progress, suppressed: message.isFailed, animated: drawnRowID == message.id) + drawImage( + for: message.id, + blobID: media.blobID, + blurhash: media.blurhash, + localImage: blurhashOnly ? nil : localImage, + remote: blurhashOnly ? nil : remote, + 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?, remote: ChatMediaLocation?, size: CGSize) { + let isSameRow = drawnRowID == rowID + drawnRowID = rowID + if !isSameRow { showUnavailable(false) } + + if let localImage { + imageView.kf.cancelDownloadTask() + imageView.image = localImage + return + } + + let preview = BlurHashCache.shared.image(for: blurhash) + guard let remote 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 + let processor = DownsamplingImageProcessor(size: size) + imageView.kf.setImage( + with: ChatMediaImageSource.source(blobID: blobID, location: remote), + placeholder: placeholder, + options: ChatMediaImageSource.options(processor: processor) + [ + .scaleFactor(traitCollection.displayScale), + .transition(.fade(Self.fadeDuration)), + ] + ) { [weak self] result in + ChatMediaImageSource.persist(result, processor: processor) + if case .failure(let error) = result, ChatMediaImageSource.isUndecryptable(error) { + self?.showUnavailable(true) + } + } + } + + /// Marks the photo as one that can't be shown: an encrypted blob that failed to authenticate or + /// wasn't the length its sender declared. It keeps its BlurHash and takes no tap. + private func showUnavailable(_ unavailable: Bool) { + unavailableLabel.isHidden = !unavailable + imageBubble.accessibilityLabel = unavailable ? ChatMediaStrings.undecryptable : "Photo" + if unavailable { imageTap.isEnabled = false } + } + + @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..32dfa42ce --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift @@ -0,0 +1,122 @@ +// +// ChatMediaImageSource.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import UIKit +import FlipcashCore +import Kingfisher + +/// Where one chat photo downloads from, and how its bytes become an image. +public struct ChatMediaLocation: Sendable { + /// The photo's signed download URL. + public let url: URL + /// Turns the downloaded blob into the image's plaintext bytes; nil for a plaintext blob. + public let decrypt: (@Sendable (Data) throws -> Data)? + + public init(url: URL, decrypt: (@Sendable (Data) throws -> Data)? = nil) { + self.url = url + self.decrypt = decrypt + } +} + +/// Where a chat photo's bytes load from, for every view that draws one. +public enum ChatMediaImageSource { + + /// The photo at `location`, 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. An + /// encrypted blob is decrypted before Kingfisher decodes it. + public static func source(blobID: BlobID?, location: ChatMediaLocation) -> Source { + let cacheKey = blobID.map { "chat-media-\($0.description)" } ?? location.url.absoluteString + guard let decrypt = location.decrypt else { + return .network(KF.ImageResource(downloadURL: location.url, cacheKey: cacheKey)) + } + return .provider(DecryptingProvider(cacheKey: cacheKey, url: location.url, decrypt: decrypt)) + } + + /// Whether a failed load was an encrypted blob that will never decrypt, rather than a fetch + /// that may succeed later. + public static func isUndecryptable(_ error: KingfisherError) -> Bool { + switch error { + case .imageSettingError(reason: .dataProviderError(_, let underlying)): + underlying is BlobOpenFailure + default: + false + } + } + + /// The cache chat photos read and write. A blob's bytes never change, so unlike the app's other + /// remote images they persist to disk and never expire from memory. + public static let cache: ImageCache = { + let cache = ImageCache(name: "chat-media") + cache.memoryStorage.config.expiration = .never + cache.diskStorage.config.sizeLimit = 500 * 1024 * 1024 + cache.diskStorage.config.expiration = .days(30) + return cache + }() + + /// The options every chat photo load passes, `processor` included when it draws a downsampled copy. + public static func options(processor: ImageProcessor? = nil) -> KingfisherOptionsInfo { + var options: KingfisherOptionsInfo = [.targetCache(cache)] + if let processor { options.append(.processor(processor)) } + return options + } + + /// Writes a freshly downloaded photo to disk under `resource`'s key. + /// + /// The app-wide `.cacheMemoryOnly` default stops Kingfisher writing it there itself, and the + /// signed URL changes per fetch, so without this every relaunch or memory trim re-downloads it. + public static func persist( + _ result: Result, + processor: ImageProcessor? = nil + ) { + guard case .success(let value) = result else { return } + persist(value.image, cacheType: value.cacheType, forKey: value.source.cacheKey, processor: processor) + } + + static func persist( + _ image: UIImage, + cacheType: CacheType, + forKey key: String, + processor: ImageProcessor?, + completion: (@Sendable () -> Void)? = nil + ) { + guard cacheType == .none else { return } + cache.store( + image, + forKey: key, + processorIdentifier: processor?.identifier ?? DefaultImageProcessor.default.identifier, + cacheSerializer: FormatIndicatedCacheSerializer.jpeg, + toDisk: true + ) { _ in completion?() } + } +} + +/// Downloads an end-to-end encrypted chat blob and hands Kingfisher its decrypted bytes. +private struct DecryptingProvider: ImageDataProvider { + let cacheKey: String + let url: URL + let decrypt: @Sendable (Data) throws -> Data + + var contentURL: URL? { url } + + func data(handler: @escaping @Sendable (Result) -> Void) { + Task { + do { + let (blob, response) = try await URLSession.shared.data(from: url) + if let status = (response as? HTTPURLResponse)?.statusCode, !(200..<300).contains(status) { + throw URLError(.badServerResponse) + } + let image = try decrypt(blob) + // The decoded image is authoritative; bytes that don't decode render as unsupported. + guard UIImage(data: image) != nil else { throw BlobOpenFailure.undecodable } + handler(.success(image)) + } catch { + handler(.failure(error)) + } + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift new file mode 100644 index 000000000..09d93989b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift @@ -0,0 +1,158 @@ +// +// 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? + /// Decrypts an end-to-end encrypted blob into its image bytes, throwing when it fails to + /// authenticate or is not the declared length. + public typealias BlobDecrypt = @Sendable (Data, BlobID, SealedBlob) throws -> Data + /// Builds the conversation's blob decryption, or returns nil while it can't be (its key is not + /// known yet). + public typealias FetchDecrypt = @MainActor () async -> BlobDecrypt? + + private let fetch: Fetch + private let fetchDecrypt: FetchDecrypt + 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]] = [:] + private var decrypt: BlobDecrypt? + /// Everyone waiting on the decryption, or nil when no fetch of it is in flight. + private var decryptWaiters: [(BlobDecrypt?) -> Void]? + + public init(fetch: @escaping Fetch, decrypt: @escaping FetchDecrypt = { nil }) { + self.fetch = fetch + self.fetchDecrypt = decrypt + } + + /// Where 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. An encrypted photo also waits + /// on the chat's decryption. + public func location( + for media: ChatMediaContent, + canReact: Bool, + onResolved: @escaping (ChatMediaLocation) -> Void + ) -> ChatMediaLocation? { + guard !ChatMediaCell.isBlurhashOnly(media, canReact: canReact), + let blobID = media.blobID else { return nil } + return resolve(blobID, sealed: media.sealed) { $0.map(onResolved) } + } + + /// Where 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:sealed:)`` + /// carries no blob), or any quote while the viewer previews a group they have not joined. + public func thumbnailLocation( + for kind: ChatQuote.Kind, + canReact: Bool, + onResolved: @escaping (ChatMediaLocation) -> Void + ) -> ChatMediaLocation? { + guard let (blobID, sealed) = Self.thumbnailBlob(for: kind, canReact: canReact) else { return nil } + return resolve(blobID, sealed: sealed) { $0.map(onResolved) } + } + + /// Where 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 thumbnailLocation(for kind: ChatQuote.Kind, canReact: Bool) async -> ChatMediaLocation? { + guard let (blobID, sealed) = Self.thumbnailBlob(for: kind, canReact: canReact) else { return nil } + return await withCheckedContinuation { continuation in + var resumed = false + let immediate = resolve(blobID, sealed: sealed) { location in + guard !resumed else { return } + resumed = true + continuation.resume(returning: location) + } + if let immediate, !resumed { + resumed = true + continuation.resume(returning: immediate) + } + } + } + + private static func thumbnailBlob(for kind: ChatQuote.Kind, canReact: Bool) -> (BlobID, SealedBlob?)? { + switch kind { + case .media(let thumbnailBlobID, let sealed): + guard canReact, let thumbnailBlobID else { return nil } + return (thumbnailBlobID, sealed) + case .text, .cash, .unavailable: + return nil + } + } + + /// The location for `blobID` when everything it needs is cached, or nil after queueing + /// `completion` on the fetches it still waits on. + private func resolve(_ blobID: BlobID, sealed: SealedBlob?, completion: @escaping (ChatMediaLocation?) -> Void) -> ChatMediaLocation? { + if let url = cache[blobID] { + guard let sealed else { return ChatMediaLocation(url: url) } + if let decrypt { return Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: decrypt) } + awaitDecrypt { decrypt in + completion(decrypt.map { Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: $0) }) + } + return nil + } + fetchURL(blobID) { [weak self] url in + guard let url else { return completion(nil) } + guard let sealed else { return completion(ChatMediaLocation(url: url)) } + if let decrypt = self?.decrypt { + return completion(Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: decrypt)) + } + self?.awaitDecrypt { decrypt in + completion(decrypt.map { Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: $0) }) + } + } + return nil + } + + private static func location(url: URL, blobID: BlobID, sealed: SealedBlob, decrypt: @escaping BlobDecrypt) -> ChatMediaLocation { + ChatMediaLocation(url: url) { try decrypt($0, blobID, sealed) } + } + + /// Queues `completion` on the one fetch of `blobID`'s URL, starting it if none is in flight. + private func fetchURL(_ blobID: BlobID, completion: @escaping (URL?) -> Void) { + let isFirst = waiters[blobID] == nil + waiters[blobID, default: []].append(completion) + guard isFirst else { return } + 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) } + } + } + + /// Queues `completion` on the one fetch of the chat's decryption, starting it if none is in flight. + private func awaitDecrypt(_ completion: @escaping (BlobDecrypt?) -> Void) { + guard decryptWaiters == nil else { + decryptWaiters?.append(completion) + return + } + decryptWaiters = [completion] + Task { + // A chat whose key is not known yet leaves its photos on their BlurHash; the next + // dequeue tries again. + let resolved = await fetchDecrypt() + if let resolved { decrypt = resolved } + let completions = decryptWaiters ?? [] + decryptWaiters = nil + for completion in completions { completion(resolved) } + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift new file mode 100644 index 000000000..c78363446 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift @@ -0,0 +1,213 @@ +// +// 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? + /// Where the photo downloads from, or nil for a pending send. + public let remote: ChatMediaLocation? + /// 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?, + remote: ChatMediaLocation?, + placeholder: UIImage?, + sourceView: @escaping () -> UIView? + ) { + guard case .media(let media) = message.content, + !ChatMediaCell.isBlurhashOnly(media, canReact: message.canReact), + !message.isFailed, + localImage != nil || remote != nil else { return nil } + self.messageID = message.id + self.blobID = media.blobID + self.localImage = localImage + self.remote = remote + 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 remote = request.remote else { return } + imageView.kf.setImage( + with: ChatMediaImageSource.source(blobID: request.blobID, location: remote), + placeholder: request.placeholder, + options: ChatMediaImageSource.options() + ) { [weak self] result in + ChatMediaImageSource.persist(result) + 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..d02896b06 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. + /// - quoteThumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + authorImageData: Data? = nil, + quoteThumbnail: ChatMediaLocation? = nil + ) { + bubble.configure(with: message, quoteThumbnail: quoteThumbnail) 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/ChatPhotoProgressOverlay.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoProgressOverlay.swift new file mode 100644 index 000000000..48d397816 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoProgressOverlay.swift @@ -0,0 +1,200 @@ +// +// ChatPhotoProgressOverlay.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI +import Observation + +/// A thin bar in the corner of an outgoing photo, after iMessage's send bar, that follows its +/// ``ChatPhotoSendProgress``. +/// +/// The bar fills with the bytes sent. Once they are stored, the server's processing and the message +/// send have no measurable progress, so a blue segment slides along the track instead — held still +/// in the middle under Reduce Motion. The bar fades out when the message is sent, and is gone at once on a failure, +/// where the row's failed receipt and tap-to-retry take over. +final class ChatPhotoProgressOverlay: UIView { + + static let size = CGSize(width: 64, height: 5) + private static let fadeDuration: TimeInterval = 0.25 + private static let fillDuration: TimeInterval = 0.2 + private static let segmentShare: CGFloat = 0.35 + private static let slideDuration: CFTimeInterval = 1.1 + private static let slideKey = "slide" + + private let track = UIView() + private let fill = UIView() + private let segment = UIView() + + private var progress: ChatPhotoSendProgress? + /// Set while the row is failed, which hides the bar whatever the progress says. + private var suppressed = false + /// Bumped on every bind, so an observation armed for an earlier row is dropped. + private var generation = 0 + + /// The share of the bar drawn filled. + private(set) var fraction: CGFloat = 0 + /// Whether the bar is showing, or fading in to show. + private(set) var isShowing = false + /// Whether the bar is in its indeterminate, post-upload state. + private(set) var isIndeterminate = false + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + isAccessibilityElement = false + alpha = 0 + isHidden = true + + // The shadow keeps the bar legible over a white region of the photo. + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.3 + layer.shadowRadius = 2 + layer.shadowOffset = .zero + + track.backgroundColor = UIColor(Color.backgroundMain).withAlphaComponent(0.35) + track.layer.cornerRadius = Self.size.height / 2 + track.clipsToBounds = true + addSubview(track) + + fill.backgroundColor = .systemBlue + track.addSubview(fill) + + segment.backgroundColor = .systemBlue + segment.layer.cornerRadius = Self.size.height / 2 + segment.isHidden = true + track.addSubview(segment) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var intrinsicContentSize: CGSize { Self.size } + + override func layoutSubviews() { + super.layoutSubviews() + track.frame = bounds + layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: bounds.height / 2).cgPath + fill.frame = CGRect(x: 0, y: 0, width: track.bounds.width * fraction, height: track.bounds.height) + let width = track.bounds.width * Self.segmentShare + segment.bounds = CGRect(x: 0, y: 0, width: width, height: track.bounds.height) + segment.center = CGPoint(x: track.bounds.midX, y: track.bounds.midY) + // A slide armed before the track had a width never started. + if isIndeterminate, segment.layer.animation(forKey: Self.slideKey) == nil { startSliding() } + } + + /// Follows `progress`, or shows nothing when it is nil. `animated` is false for a row the + /// overlay was not already drawing, so a recycled cell never fades another row's state. + func bind(_ progress: ChatPhotoSendProgress?, suppressed: Bool, animated: Bool) { + generation += 1 + self.progress = progress + self.suppressed = suppressed + render(animated: animated) + observe(generation: generation) + } + + private func observe(generation armed: Int) { + guard let progress else { return } + withObservationTracking { + _ = progress.phase + } onChange: { [weak self] in + // Fires before the new value is readable; render once it has landed. + Task { @MainActor [weak self] in + guard let self, self.generation == armed else { return } + self.render(animated: true) + self.observe(generation: armed) + } + } + } + + private func render(animated: Bool) { + let phase = suppressed ? nil : progress?.phase + let animated = animated && window != nil + + let target: (show: Bool, fraction: CGFloat, indeterminate: Bool) + switch phase { + case .preparing: + target = (true, 0, false) + case .uploading(let value): + target = (true, CGFloat(value), false) + case .processing, .sending: + target = (true, 1, true) + case .sent: + target = (false, 1, false) + case .failed, nil: + target = (false, fraction, false) + } + + if target.show { + setFraction(target.fraction, animated: animated) + setIndeterminate(target.indeterminate) + } + // A failure hands straight to the failed receipt. Anything else leaving fades: a send, or a + // row whose progress went away because its send confirmed. + setShowing(target.show, animated: animated && !suppressed && phase != .failed) + } + + private func setFraction(_ value: CGFloat, animated: Bool) { + guard value != fraction else { return } + fraction = value + guard animated, !UIAccessibility.isReduceMotionEnabled, !isHidden else { + setNeedsLayout() + return + } + UIView.animate(withDuration: Self.fillDuration, delay: 0, options: [.beginFromCurrentState, .curveEaseOut]) { + self.layoutIfNeeded() + } + setNeedsLayout() + } + + private func setIndeterminate(_ indeterminate: Bool) { + guard indeterminate != isIndeterminate else { return } + isIndeterminate = indeterminate + fill.isHidden = indeterminate + segment.isHidden = !indeterminate + if indeterminate { startSliding() } else { stopSliding() } + } + + private func startSliding() { + guard !UIAccessibility.isReduceMotionEnabled, track.bounds.width > 0 else { return } + let half = segment.bounds.width / 2 + let slide = CABasicAnimation(keyPath: "position.x") + slide.fromValue = -half + slide.toValue = track.bounds.width + half + slide.duration = Self.slideDuration + slide.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + slide.repeatCount = .infinity + slide.isRemovedOnCompletion = false + segment.layer.add(slide, forKey: Self.slideKey) + } + + private func stopSliding() { + segment.layer.removeAnimation(forKey: Self.slideKey) + } + + private func setShowing(_ show: Bool, animated: Bool) { + guard show != isShowing else { return } + isShowing = show + if show { + isHidden = false + layoutIfNeeded() + } + let apply = { self.alpha = show ? 1 : 0 } + guard animated else { + apply() + isHidden = !show + if !show { setIndeterminate(false) } + return + } + UIView.animate(withDuration: Self.fadeDuration, delay: 0, options: [.beginFromCurrentState], animations: apply) { _ in + guard !self.isShowing else { return } + self.isHidden = true + self.setIndeterminate(false) + } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift new file mode 100644 index 000000000..a8ff05d43 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift @@ -0,0 +1,88 @@ +// +// ChatPhotoSendProgress.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Observation +import FlipcashCore + +/// How far one outgoing photo has got, from encoding through the message that carries it. +/// +/// One per staged photo, so each bubble in a multi-photo send draws its own progress. +@MainActor +@Observable +public final class ChatPhotoSendProgress { + + public enum Phase: Equatable, Sendable { + /// Encoding, encrypting, or reserving the upload; no bytes have gone out yet. + case preparing + /// Sending bytes to storage, with the share of them sent. + case uploading(fraction: Double) + /// Stored, while the server checks the bytes before they can be served. + case processing + /// Posting the message that references the stored photo. + case sending + /// The message is confirmed. + case sent + /// The upload or the message failed; the transcript's retry affordance takes over. + case failed + } + + public private(set) var phase: Phase = .preparing + + public init() {} + + /// Whether the bubble draws a progress overlay for this phase. + public var showsOverlay: Bool { + switch phase { + case .preparing, .uploading, .processing, .sending: + true + case .sent, .failed: + false + } + } + + /// Starts an upload attempt over, including after a failure or a retried store. + public func beginAttempt() { + phase = .preparing + } + + /// Records bytes sent to storage. + /// + /// Byte counts arrive asynchronously from the network, so one landing after the upload has + /// moved on is ignored, and the bar never runs backwards within an attempt. + public func didUpload(_ progress: BlobUploadProgress) { + guard let fraction = progress.fraction else { return } + switch phase { + case .preparing: + phase = .uploading(fraction: fraction) + case .uploading(let current): + if fraction > current { phase = .uploading(fraction: fraction) } + case .processing, .sending, .sent, .failed: + break + } + } + + /// Records that the bytes are stored and the server is finalizing them. + public func beginProcessing() { + phase = .processing + } + + /// Records that the message referencing the photo is being posted. + public func beginSending() { + phase = .sending + } + + /// Records that the message is confirmed. + public func finish() { + phase = .sent + } + + /// Records that the upload or the message failed. + public func fail() { + phase = .failed + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 885905a8b..17bda467e 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 thumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. + func configure(with quote: ChatQuote, thumbnail: ChatMediaLocation? = 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,14 +196,21 @@ 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) + showThumbnail(blobID: nil, location: nil) + case .media(let thumbnailBlobID, _): + flagView.isHidden = true + tokenLabel.isHidden = true + snippetLabel.textColor = Self.snippetColor + showThumbnail(blobID: thumbnailBlobID, location: thumbnail) case .text, .unavailable: flagView.isHidden = true tokenLabel.isHidden = true snippetLabel.textColor = Self.snippetColor + showThumbnail(blobID: nil, location: nil) } let spoken = switch quote.kind { case .cash(let token, _): "\(quote.snippet) \(token)" - case .text, .unavailable: quote.snippet + case .text, .media, .unavailable: quote.snippet } isUserInteractionEnabled = quote.isJumpable accessibilityLabel = quote.authorName.isEmpty @@ -197,10 +230,42 @@ final class ChatQuotePanelView: UIView { flagView.image = nil flagView.isHidden = true tokenLabel.isHidden = true + showThumbnail(blobID: nil, location: nil) isUserInteractionEnabled = false accessibilityLabel = nil } + /// Shows the thumbnail slot for a photo with a blob, loading it once `location` 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?, location: ChatMediaLocation?) { + 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 location else { + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + return + } + let side = Self.thumbnailSide + let processor = DownsamplingImageProcessor(size: CGSize(width: side, height: side)) + thumbnailView.kf.setImage( + with: ChatMediaImageSource.source(blobID: blobID, location: location), + options: ChatMediaImageSource.options(processor: processor) + [ + .scaleFactor(traitCollection.displayScale), + ] + ) { result in + ChatMediaImageSource.persist(result, processor: processor) + } + } + @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..0e559c536 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,24 @@ 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 } + } + + /// A pending photo's send progress — see ``ChatViewController/pendingMediaProgress``. + public var pendingMediaProgress: ((String) -> ChatPhotoSendProgress?)? { + get { transcript.pendingMediaProgress } + set { transcript.pendingMediaProgress = newValue } + } + public var onContactAction: (() -> Void)? { get { transcript.onContactAction } set { transcript.onContactAction = newValue } @@ -292,6 +336,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 +689,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. /// @@ -687,6 +780,10 @@ public final class ChatScreenViewController: UIViewController { holdForInPlaceResize(clipHeight: clipHeight) return } + if clipHeight < previousClip, !toggled, !isFirst, view.window != nil { + holdForShrink(clipHeight: clipHeight) + return + } transcriptCover = nil fadeTopConstraint?.constant = 0 @@ -711,6 +808,33 @@ public final class ChatScreenViewController: UIViewController { } } + /// Shrinks the bar's room for a composer folding back to one row or dropping its chips. + /// + /// The transcript takes the new inset in this frame, unanimated, so a send's insertion moves its + /// rows on its own spring alone. The clip stays at its old height until the glass's spring is + /// done: cut at once, it would hide the text and caret still travelling down to their place. + private func holdForShrink(clipHeight: CGFloat) { + let heldClip = barClipHeightConstraint.constant + transcriptCover = clipHeight + fadeTopConstraint.constant = heldClip - clipHeight + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + self.updateTranscriptInset() + } + let height = measuredBarHeight + DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.replySurface.duration) { [weak self] in + // A later report may already have moved the bar on; only the one still current lands. + guard let self, self.measuredBarHeight == height, + self.barClipHeightConstraint.constant == heldClip else { return } + self.barClipHeightConstraint.constant = clipHeight + self.transcriptCover = nil + self.fadeTopConstraint.constant = 0 + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + } + } + } + /// Makes room for a change the bar draws itself, without animating anything. /// /// The glass grows and shrinks on its own spring, its bottom on the composer, and a second @@ -831,7 +955,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 +1066,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..fb2a9ddfd 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -118,6 +118,20 @@ 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 send progress of a photo this device is still sending, by the pending row's id; the row + /// draws it over the photo until the send confirms or fails. + public var pendingMediaProgress: ((String) -> ChatPhotoSendProgress?)? /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 @@ -325,6 +339,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 +652,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, + quoteThumbnail: quoteThumbnail(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 +672,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, + quoteThumbnail: quoteThumbnail(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 +698,68 @@ 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 remote = mediaURLResolver?.location(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, + progress: pendingMediaProgress?(message.id), + remote: remote, + authorImageData: authorImageData + ) + cell.onImageTap = { [weak self, weak cell] in + guard let self, let request = ChatMediaViewerRequest( + message: message, + localImage: localImage, + remote: remote, + 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 quoteThumbnail(for message: ChatMessage) -> ChatMediaLocation? { + guard let quote = message.quote else { return nil } + return mediaURLResolver?.thumbnailLocation(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 +850,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..705746424 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; `quoteThumbnail` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnail: ChatMediaLocation? = 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, thumbnail: quoteThumbnail) NSLayoutConstraint.deactivate(quoteCollapse + [textTopToBubble, cardTopToBubble]) quoteTrailing.isActive = true textTopToQuote.isActive = !bare diff --git a/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift index d0371e6df..d8982d591 100644 --- a/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift +++ b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift @@ -182,6 +182,107 @@ nonisolated public enum BlurHash { return UIImage(cgImage: cgImage) } + // MARK: - Encode - + + /// Encodes `image` into a hash with `componentsX` × `componentsY` components (each 1…9), or nil + /// when the image cannot be drawn. + /// + /// The image is sampled at a small fixed size first: a hash keeps only the lowest frequencies, so + /// more pixels change nothing but the cost. Squashing it to a square is harmless for the same + /// reason; the decoder stretches back to whatever aspect it is drawn at. + public static func encode(_ image: CGImage, componentsX: Int, componentsY: Int) -> String? { + guard (1...9).contains(componentsX), (1...9).contains(componentsY) else { return nil } + let side = 32 + let bytesPerRow = side * 4 + var pixels = [UInt8](repeating: 0, count: side * bytesPerRow) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return false } + context.interpolationQuality = .medium + context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side)) + return true + } + guard drawn else { return nil } + return encode(rgb: pixels, width: side, height: side, bytesPerRow: bytesPerRow, componentsX: componentsX, componentsY: componentsY) + } + + /// Encodes 8-bit sRGB pixels laid out as RGBx rows, the reference implementation's algorithm. + static func encode(rgb pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int, componentsX: Int, componentsY: Int) -> String { + var linear = [SIMD3](repeating: .zero, count: width * height) + for y in 0..] = [] + for j in 0...zero + for y in 0.., _ maxValue: Float) -> Int { + func quantise(_ component: Float) -> Int { + let scaled = component / maxValue + let root = scaled < 0 ? -sqrtf(-scaled) : sqrtf(scaled) + return max(0, min(18, Int(floorf(root * 9 + 9.5)))) + } + return quantise(value.x) * 19 * 19 + quantise(value.y) * 19 + quantise(value.z) + } + + private static func encode83(_ value: Int, length: Int) -> String { + var result = "" + for i in 1...length { + var divisor = 1 + for _ in 0..<(length - i) { divisor *= 83 } + result.append(alphabet[(value / divisor) % 83]) + } + return result + } + private static let alphabet = Array( "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" ) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 494dc60fc..8a2217c80 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -34,6 +34,30 @@ extension View { } } + /// Dark-tinted glass for a control floating over a photo or viewfinder, where clear glass takes + /// on the image behind it and washes the glyph out. + @ViewBuilder + public func overlayGlassBackground(in shape: some Shape) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(.black.opacity(0.45)).interactive(), in: shape) + } else { + background(Color.black.opacity(0.45), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + + /// 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 @@ -61,4 +85,34 @@ extension View { background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius)) } } + + /// The chat composer's glass in `shape`, as a layer of its own: Liquid Glass with a light white + /// tint, joined by `id` to the other composer glass in an enclosing container. A tinted material + /// before iOS 26. + @ViewBuilder + public func composerGlass(in shape: some Shape, id: String, namespace: Namespace.ID) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(Color.white.opacity(0.03)).interactive(), in: shape) + .glassEffectID(id, in: namespace) + } else { + background(Color.white.opacity(0.03), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + + /// The chat composer's light rim around `shape`, brightest along the top. + public func composerRim(in shape: some InsettableShape) -> some View { + overlay { + shape + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.18), Color.white.opacity(0.04)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .allowsHitTesting(false) + } + } } diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index 869e25e0d..c12eadd01 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 0147318e1..fc7eeaea0 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. @@ -62,9 +65,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) } }