| index 8919142..fd556bc 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -82,7 +82,7 @@ struct NativeChatScreen: View { |
| @State private var showFilePicker = false |
| @State private var staged: [StagedUpload] = [] |
| @State private var sendQueued = false |
| - @State private var viewerImage: IdentifiedImage? |
| + @State private var quickLookItem: QuickLookItem? |
| @State private var playingVideo: PlayingVideo? |
| // A downloaded file staged on disk for Quick Look, plus the temp directory |
| // to burn once the preview closes (decrypted plaintext never lingers). |
| @@ -102,12 +102,6 @@ struct NativeChatScreen: View { |
| var attachment: Attachment? |
| } |
| |
| - struct IdentifiedImage: Identifiable { |
| - let id: String |
| - let image: PlatformImage |
| - let sourceFrame: CGRect // the bubble thumbnail's window frame — where the zoom starts |
| - } |
| - |
| struct PlayingVideo: Identifiable { |
| let id: String |
| let player: AVPlayer |
| @@ -507,12 +501,16 @@ struct NativeChatScreen: View { |
| withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true } |
| } |
| } |
| - .fullScreenCover(item: $viewerImage) { item in |
| - ImageViewer(image: item.image, sourceFrame: item.sourceFrame) { |
| - coverWithoutSlide { viewerImage = nil } |
| - } |
| - .presentationBackground(.clear) |
| - } |
| + // Tapping a photo opens it in Quick Look — which zooms out of the |
| + // tapped bubble (and back on dismiss), and carries Apple's own |
| + // chrome plus the system Share / Save to Photos / Markup buttons. |
| + // An invisible presenter drives QLPreviewController imperatively from |
| + // the thumbnail's frame; the decrypted temp file is burned on |
| + // dismiss (cleanupPreview), so plaintext never lingers. |
| + .background(ImageQuickLookPresenter(item: $quickLookItem) { |
| + cleanupPreview() |
| + quickLookItem = nil |
| + }) |
| .fullScreenCover(item: $playingVideo) { item in |
| VideoViewer(player: item.player, sourceFrame: item.sourceFrame) { |
| coverWithoutSlide { playingVideo = nil } |
| @@ -550,17 +548,33 @@ struct NativeChatScreen: View { |
| withTransaction(t, change) |
| } |
| |
| - // The feed hands the lightbox its downsampled thumbnail so the zoom opens |
| - // instantly; the full-fidelity decode (off-main, cached under its own size |
| - // class) swaps in underneath once it lands — unless the viewer has already |
| - // moved on to another photo or closed. |
| - private func upgradeViewerImage(_ att: Attachment) { |
| - guard let source = model.blobSource(att) else { return } |
| + // A tapped photo: decrypt the ORIGINAL blob to a temp file (Quick Look reads |
| + // by URL and renders the full-fidelity image itself), then open the preview |
| + // zooming out of the tapped thumbnail. `thumbnail` is the zoom-transition |
| + // anchor; `sourceFrame` is where it zooms from / back to. Reuses the same |
| + // temp-file staging + cleanup the file preview uses (a photo and a file tap |
| + // are mutually exclusive, so they share previewCleanupDir safely). |
| + private func openQuickLook(_ att: Attachment, thumbnail: PlatformImage, sourceFrame: CGRect) { |
| + guard blobDownload == nil else { return } |
| + blobDownload = (att.id, 0) |
| + let started = Date() |
| Task { |
| - guard let full = await BlobImageLoader.shared.image(source), |
| - let current = viewerImage, current.id == att.id else { return } |
| - viewerImage = IdentifiedImage(id: att.id, image: full, |
| - sourceFrame: current.sourceFrame) |
| + let data = await model.blobData(att) { p in |
| + if Date().timeIntervalSince(started) > 0.15 { blobDownload = (att.id, p) } |
| + } |
| + blobDownload = nil |
| + guard let data, |
| + let url = stagePreviewFile( |
| + data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type)) |
| + else { |
| + setRouteNotice("Couldn’t open that photo.") |
| + return |
| + } |
| + // Dismissing Quick Look re-fires the feed's onAppear; don't let it |
| + // re-pin to the bottom and strand this photo off-screen. |
| + suppressSettleOnReturn = true |
| + quickLookItem = QuickLookItem(id: att.id, url: url, |
| + thumbnail: thumbnail, sourceFrame: sourceFrame) |
| } |
| } |
| |
| @@ -575,6 +589,11 @@ struct NativeChatScreen: View { |
| // unread-cutoff opens). The sentinel's onAppear uses it to tell "the |
| // opening pin needs me" from an ordinary later scroll to the bottom. |
| @State private var pinningToBottom = false |
| + // Quick Look is a full-screen modal, so dismissing it re-fires the feed's |
| + // onAppear — which would re-run settle() and yank a scrolled-up reader back |
| + // to the bottom, stranding the photo they just viewed off-screen. Set when |
| + // we open the viewer, it makes that one return-appear skip the pin. |
| + @State private var suppressSettleOnReturn = false |
| |
| private func settle(_ proxy: ScrollViewProxy) { |
| guard !model.messages.isEmpty else { |
| @@ -824,8 +843,14 @@ struct NativeChatScreen: View { |
| } |
| .onAppear { |
| // A parked model is ready immediately — onChange(ready) never |
| - // fires on a revisit, so settle from here too. |
| - if model.ready { settle(proxy) } |
| + // fires on a revisit, so settle from here too. But skip it when |
| + // returning from Quick Look: re-pinning would strand the |
| + // just-viewed photo off-screen (see suppressSettleOnReturn). |
| + if suppressSettleOnReturn { |
| + suppressSettleOnReturn = false |
| + } else if model.ready { |
| + settle(proxy) |
| + } |
| // Arm the arrival animation only after the opening render has |
| // settled (parked models are ready immediately). |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { landed = true } |
| @@ -900,11 +925,7 @@ struct NativeChatScreen: View { |
| onOpenThread: { root in openThread(root) }, |
| blobSource: { model.blobSource($0) }, |
| onImageTap: { att, image, frame in |
| - coverWithoutSlide { |
| - viewerImage = IdentifiedImage( |
| - id: att.id, image: image, sourceFrame: frame) |
| - } |
| - upgradeViewerImage(att) |
| + openQuickLook(att, thumbnail: image, sourceFrame: frame) |
| }, |
| onFileTap: { openFile($0) }, |
| onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| @@ -1018,11 +1039,7 @@ struct NativeChatScreen: View { |
| theme: .named(themeName), |
| blobSource: { model.blobSource($0) }, |
| onImageTap: { att, image, frame in |
| - coverWithoutSlide { |
| - viewerImage = IdentifiedImage( |
| - id: att.id, image: image, sourceFrame: frame) |
| - } |
| - upgradeViewerImage(att) |
| + openQuickLook(att, thumbnail: image, sourceFrame: frame) |
| }, |
| onFileTap: { openFile($0) }, |
| onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| @@ -1798,236 +1815,6 @@ struct NativeChatScreen: View { |
| } |
| } |
| |
| -// The full-screen photo viewer, Messages-style. The tapped photo ZOOMS OUT OF |
| -// ITS BUBBLE to an edge-to-edge fit over a fading black scrim (the cover |
| -// itself presents with no transition — this view is the whole choreography), |
| -// and closing zooms it back into place. While open it's freely manipulable: |
| -// pinch zooms about the fingers and KEEPS its zoom, drag pans while zoomed |
| -// (or pulls to dismiss at 1:1, the photo following the finger), twist rotates |
| -// 1:1 and springs back level, double-tap toggles zoom at the tapped spot. |
| -private struct ImageViewer: View { |
| - let image: PlatformImage |
| - let sourceFrame: CGRect // the bubble thumbnail, in window coordinates |
| - let onClose: () -> Void |
| - |
| - // Drives the scrim and the bubble↔fullscreen geometry (the Mac lightbox's |
| - // pattern): sprung on in onAppear, off in close() with delayed teardown. |
| - @State private var shown = false |
| - @State private var closing = false |
| - |
| - // The gesture pose, applied on top of the fitted geometry. |
| - @State private var scale: CGFloat = 1 |
| - @State private var pinchBase: CGFloat = 1 |
| - @State private var offset: CGSize = .zero |
| - @State private var panBase: CGSize = .zero |
| - @State private var rotation: Angle = .zero |
| - @State private var dismissOffset: CGFloat = 0 |
| - |
| - private let maxZoom: CGFloat = 5 |
| - private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) } |
| - |
| - var body: some View { |
| - ZStack(alignment: .topTrailing) { |
| - GeometryReader { geo in |
| - stage(in: geo.size) |
| - } |
| - // Edge-to-edge: the stage spans the whole screen, so its local |
| - // coordinates are window coordinates — the space sourceFrame is in. |
| - .ignoresSafeArea() |
| - // The close button stays inside the safe area, above the stage. |
| - if shown, !closing, dismissOffset == 0 { |
| - Button(action: close) { |
| - Image(systemName: "xmark.circle.fill") |
| - .font(.system(size: 28)) |
| - .foregroundStyle(.white.opacity(0.8)) |
| - .padding() |
| - } |
| - .accessibilityLabel("Close") |
| - .transition(.opacity) |
| - } |
| - } |
| - // Sprung from onAppear (not init) so the first frame still renders the |
| - // photo exactly over its bubble — a seamless start to the zoom. |
| - .onAppear { |
| - withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { shown = true } |
| - } |
| - } |
| - |
| - private func stage(in viewport: CGSize) -> some View { |
| - let target = fittedRect(in: viewport) |
| - // A frame that never got captured (shouldn't happen) degrades to a |
| - // fade-up from center rather than a zoom from nowhere. |
| - let src = sourceFrame.isEmpty |
| - ? CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0) |
| - : sourceFrame |
| - return ZStack { |
| - // The backdrop FADES (fully — the chat shows through the clear |
| - // cover); only the photo itself rides the swipe. |
| - Color.black |
| - .opacity(shown ? Double(1 - dismissProgress * 0.9) : 0) |
| - .onTapGesture(perform: close) |
| - Image(platform: image) |
| - .resizable() |
| - .interpolation(.high) |
| - .frame(width: shown ? target.width : src.width, |
| - height: shown ? target.height : src.height) |
| - .clipShape(RoundedRectangle(cornerRadius: shown ? 0 : 18, style: .continuous)) |
| - .scaleEffect(scale * (1 - dismissProgress * 0.15)) |
| - .rotationEffect(rotation) |
| - .position(x: (shown ? target.midX : src.midX) + offset.width, |
| - y: (shown ? target.midY : src.midY) + offset.height + dismissOffset) |
| - .onTapGesture(count: 2) { loc in |
| - doubleTap(at: loc, target: target, viewport: viewport) |
| - } |
| - .onTapGesture { if scale == 1 { close() } } |
| - .gesture(drag(target: target, viewport: viewport)) |
| - .simultaneousGesture(pinch(target: target, viewport: viewport)) |
| - .simultaneousGesture(rotate) |
| - } |
| - } |
| - |
| - // Aspect-fit to the FULL screen — the photo meets the edges (scaled up if |
| - // needed, like Messages), letterboxed by the scrim on the other axis. |
| - private func fittedRect(in viewport: CGSize) -> CGRect { |
| - let size = image.size |
| - guard size.width > 0, size.height > 0, viewport.width > 0, viewport.height > 0 else { |
| - return CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0) |
| - } |
| - let s = min(viewport.width / size.width, viewport.height / size.height) |
| - let w = size.width * s |
| - let h = size.height * s |
| - return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, |
| - width: w, height: h) |
| - } |
| - |
| - // Everything springs home together — the pose to identity, the geometry |
| - // back into the bubble, the scrim out — then the animation-less cover |
| - // clears once the zoom-back has played. |
| - private func close() { |
| - guard !closing else { return } |
| - closing = true |
| - withAnimation(.spring(response: 0.32, dampingFraction: 0.88)) { |
| - shown = false |
| - scale = 1 |
| - offset = .zero |
| - rotation = .zero |
| - dismissOffset = 0 |
| - } |
| - pinchBase = 1 |
| - panBase = .zero |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.32, execute: onClose) |
| - } |
| - |
| - // Zoom in about the tapped spot; zoom out back to the fit. |
| - private func doubleTap(at loc: CGPoint, target: CGRect, viewport: CGSize) { |
| - if scale > 1 { |
| - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { |
| - scale = 1 |
| - offset = .zero |
| - } |
| - } else { |
| - // q: the tapped point in the photo's own (unscaled, centered) |
| - // coordinates — hold it in place while the photo grows around it. |
| - let q = CGPoint(x: (loc.x - target.midX - offset.width) / scale, |
| - y: (loc.y - target.midY - offset.height) / scale) |
| - let zoomed: CGFloat = 2.5 |
| - let held = CGSize(width: offset.width + (scale - zoomed) * q.x, |
| - height: offset.height + (scale - zoomed) * q.y) |
| - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { |
| - scale = zoomed |
| - offset = clamped(held, scale: zoomed, fitted: target.size, in: viewport) |
| - } |
| - } |
| - pinchBase = scale |
| - panBase = offset |
| - } |
| - |
| - // One drag, two meanings: pan while zoomed in, pull-to-dismiss at 1:1. |
| - private func drag(target: CGRect, viewport: CGSize) -> some Gesture { |
| - DragGesture() |
| - .onChanged { v in |
| - if scale > 1 { |
| - offset = CGSize(width: panBase.width + v.translation.width, |
| - height: panBase.height + v.translation.height) |
| - } else { |
| - dismissOffset = v.translation.height |
| - } |
| - } |
| - .onEnded { v in |
| - if scale > 1 { |
| - // Rubber-band any letterbox dragged into view back out. |
| - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { |
| - offset = clamped(offset, scale: scale, |
| - fitted: target.size, in: viewport) |
| - } |
| - panBase = offset |
| - } else if abs(dismissOffset) > 120 || abs(v.predictedEndTranslation.height) > 400 { |
| - close() |
| - } else { |
| - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { |
| - dismissOffset = 0 |
| - } |
| - } |
| - } |
| - } |
| - |
| - // Messages-style pinch: the photo point under the fingers stays under |
| - // them, and the zoom KEEPS on release — clamped back into [1, maxZoom] |
| - // (with rubber give past both ends while the fingers are down). |
| - private func pinch(target: CGRect, viewport: CGSize) -> some Gesture { |
| - MagnifyGesture() |
| - .onChanged { v in |
| - let s = softClamped(pinchBase * v.magnification) |
| - let q = CGPoint(x: (v.startLocation.x - target.midX - panBase.width) / pinchBase, |
| - y: (v.startLocation.y - target.midY - panBase.height) / pinchBase) |
| - scale = s |
| - offset = CGSize(width: panBase.width + (pinchBase - s) * q.x, |
| - height: panBase.height + (pinchBase - s) * q.y) |
| - } |
| - .onEnded { _ in |
| - let settled = min(maxZoom, max(1, scale)) |
| - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { |
| - scale = settled |
| - offset = settled == 1 |
| - ? .zero |
| - : clamped(offset, scale: settled, fitted: target.size, in: viewport) |
| - } |
| - pinchBase = settled |
| - panBase = offset |
| - } |
| - } |
| - |
| - // Twist turns the photo 1:1 under the fingers (Messages-style) and springs |
| - // back level on release — the zoom stays, the tilt never does. |
| - private var rotate: some Gesture { |
| - RotateGesture() |
| - .onChanged { rotation = $0.rotation } |
| - .onEnded { _ in |
| - withAnimation(.spring(response: 0.45, dampingFraction: 0.72)) { |
| - rotation = .zero |
| - } |
| - } |
| - } |
| - |
| - // Rubber give past the zoom limits: the photo keeps responding at a |
| - // root-curve fraction of the overshoot, and the release springs it back. |
| - private func softClamped(_ raw: CGFloat) -> CGFloat { |
| - if raw > maxZoom { return maxZoom * pow(raw / maxZoom, 0.4) } |
| - if raw < 1 { return max(0.55, pow(raw, 0.6)) } |
| - return raw |
| - } |
| - |
| - // Pan bounds: no dead letterbox on an axis where the zoomed photo exceeds |
| - // the screen; centered on an axis where it doesn't. |
| - private func clamped(_ o: CGSize, scale: CGFloat, fitted: CGSize, |
| - in viewport: CGSize) -> CGSize { |
| - let maxX = max(0, (fitted.width * scale - viewport.width) / 2) |
| - let maxY = max(0, (fitted.height * scale - viewport.height) / 2) |
| - return CGSize(width: min(maxX, max(-maxX, o.width)), |
| - height: min(maxY, max(-maxY, o.height))) |
| - } |
| -} |
| - |
| // Full-screen encrypted-video playback — an AVPlayerViewController wrapped so |
| // its letterbox is TRANSPARENT (PlayerSurface): the backdrop is our own layer, |
| // so the dismiss swipe translates only the video surface (scaling down as it |
| @@ -2049,8 +1836,8 @@ private struct VideoViewer: View { |
| let sourceFrame: CGRect // the video card, in window coordinates |
| let onClose: () -> Void |
| |
| - // Drives the scrim and the card↔fullscreen geometry, as in ImageViewer: |
| - // sprung on in onAppear, off in close() with delayed teardown. |
| + // Drives the scrim and the card↔fullscreen geometry: sprung on in onAppear, |
| + // off in close() with delayed teardown. |
| @State private var shown = false |
| @State private var closing = false |
| @State private var dismissOffset: CGFloat = 0 |