diff --git a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec index 7bcaf22aaa..5503528db2 100644 --- a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec +++ b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec @@ -10,6 +10,8 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' + # Must match the version pinned by expo-image so we share SDImageCache.shared. + s.dependency 'SDWebImage', '~> 5.21.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', diff --git a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift index 10efb34bdf..caa3429c13 100644 --- a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift +++ b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift @@ -59,12 +59,11 @@ class ExpoBlueskyContextMenuView: ExpoView, UIContextMenuInteractionDelegate { return makeTargetedPreview() } - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - return makeTargetedPreview() - } + // NOTE: Intentionally not implementing + // `previewForDismissingMenuWithConfiguration`. iOS reverses the highlight + // animation by default, which interpolates size + position together. Supplying + // a separate dismiss preview tends to produce a two-stage animation on + // aspect-mismatched thumbnails (snap to original size, then translate). func contextMenuInteraction( _ interaction: UIContextMenuInteraction, diff --git a/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift b/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift index 12b72aa662..7daa94e5dd 100644 --- a/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift +++ b/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift @@ -1,3 +1,4 @@ +import SDWebImage import UIKit /// Preview view controller shown during a peek. Renders a single image sized @@ -6,12 +7,25 @@ import UIKit /// The aspect ratio drives `preferredContentSize` so iOS animates directly to /// the final size without the mid-flight stretch that happens when a mis-sized /// snapshot is scaled up. +/// +/// Image loading cooperates with expo-image by sharing +/// `SDImageCache.shared` and `SDWebImageManager.shared`: +/// 1. Query the cache synchronously for the fullsize — if it's there +/// (e.g. prefetched on press-in), paint it immediately. +/// 2. Else, paint the thumbnail (almost always cached — it's what the feed +/// renders) as a placeholder. +/// 3. Asynchronously load the fullsize and swap it in when it arrives. +/// This eliminates the "black flash" on first peek of an unloaded image. final class ImagePreviewController: UIViewController { private let imageURL: URL? + private let thumbURL: URL? private let aspectRatio: CGFloat - init(imageURL: URL?, aspectRatio: CGFloat) { + private let imageView = UIImageView() + + init(imageURL: URL?, thumbURL: URL?, aspectRatio: CGFloat) { self.imageURL = imageURL + self.thumbURL = thumbURL self.aspectRatio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1 super.init(nibName: nil, bundle: nil) self.preferredContentSize = Self.sizeForAspect(self.aspectRatio) @@ -24,36 +38,54 @@ final class ImagePreviewController: UIViewController { root.backgroundColor = .black root.clipsToBounds = true - let imageView = UIImageView() + // Use autoresizing mask rather than AutoLayout so the imageView's frame + // interpolates cleanly during the dismiss animation — AutoLayout-driven + // relayout during a CALayer animation can cause a visible snap. + imageView.frame = root.bounds + imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] imageView.contentMode = .scaleAspectFit - imageView.translatesAutoresizingMaskIntoConstraints = false imageView.backgroundColor = .black root.addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: root.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: root.trailingAnchor), - imageView.topAnchor.constraint(equalTo: root.topAnchor), - imageView.bottomAnchor.constraint(equalTo: root.bottomAnchor), - ]) - self.view = root - load(into: imageView) + primeImage() } - private func load(into imageView: UIImageView) { - guard let url = imageURL else { return } - // Use URLSession + URLCache so we cooperate with Expo Image's HTTP cache. - let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 10) - if let cached = URLCache.shared.cachedResponse(for: request), - let image = UIImage(data: cached.data) { - imageView.image = image + // MARK: - Image loading + + private func primeImage() { + // 1. Fullsize cache hit? Paint it immediately. + if let url = imageURL, let cached = cachedImage(for: url) { + imageView.image = cached return } - URLSession.shared.dataTask(with: request) { [weak imageView] data, _, _ in - guard let data = data, let image = UIImage(data: data) else { return } - DispatchQueue.main.async { imageView?.image = image } - }.resume() + // 2. Thumb placeholder (almost always cached by the feed). + if let thumb = thumbURL, let cached = cachedImage(for: thumb) { + imageView.image = cached + } + // 3. Kick off the async fullsize load. + guard let url = imageURL else { return } + SDWebImageManager.shared.loadImage( + with: url, + options: [.retryFailed], + progress: nil + ) { [weak self] image, _, _, _, _, _ in + guard let self = self, let image = image else { return } + DispatchQueue.main.async { + self.imageView.image = image + } + } + } + + /// Synchronous cache lookup across memory + disk. Memory hits are instant; + /// disk hits incur a small read but remain on-thread (matches what SDWebImage + /// does when the cache policy permits). + private func cachedImage(for url: URL) -> UIImage? { + let key = SDWebImageManager.shared.cacheKey(for: url) ?? url.absoluteString + if let memory = SDImageCache.shared.imageFromMemoryCache(forKey: key) { + return memory + } + return SDImageCache.shared.imageFromDiskCache(forKey: key) } /// Caps the preview to a comfortable size within the current key window. diff --git a/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift b/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift index 36c3ffa0ef..a0acf62887 100644 --- a/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift +++ b/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift @@ -11,9 +11,15 @@ enum PreviewFactory { switch type { case "image": let uri = spec["uri"] as? String + let thumbUri = spec["thumbUri"] as? String let url = uri.flatMap(URL.init(string:)) + let thumbURL = thumbUri.flatMap(URL.init(string:)) let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1) - return ImagePreviewController(imageURL: url, aspectRatio: aspect) + return ImagePreviewController( + imageURL: url, + thumbURL: thumbURL, + aspectRatio: aspect + ) default: return nil } diff --git a/modules/expo-bluesky-context-menu/src/types.ts b/modules/expo-bluesky-context-menu/src/types.ts index 2f2abe761a..d3eb10027c 100644 --- a/modules/expo-bluesky-context-menu/src/types.ts +++ b/modules/expo-bluesky-context-menu/src/types.ts @@ -14,6 +14,10 @@ export type PreviewContent = | { type: 'image' uri: string + /** Thumb URL. When present, the native side paints it in as an instant + * placeholder (reading from the shared SDWebImage cache) while the + * fullsize loads — avoids the black flash on first peek. */ + thumbUri?: string /** Aspect ratio as width / height. */ aspectRatio: number } diff --git a/src/components/Post/Embed/ImageContextMenu.tsx b/src/components/Post/Embed/ImageContextMenu.tsx index b6bed6c49d..d70c3f7899 100644 --- a/src/components/Post/Embed/ImageContextMenu.tsx +++ b/src/components/Post/Embed/ImageContextMenu.tsx @@ -20,6 +20,7 @@ import * as ContextMenu from '../../../../modules/expo-bluesky-context-menu' */ export function ImageContextMenu({ fullsizeUri, + thumbUri, aspectRatio, borderRadius, onPreviewPress, @@ -27,6 +28,9 @@ export function ImageContextMenu({ children, }: { fullsizeUri: string + /** Thumbnail URL. Used as an instant placeholder in the native preview + * while the fullsize loads, so there's no black flash on first peek. */ + thumbUri?: string /** width / height; defaults to 1 if missing. */ aspectRatio: number | undefined borderRadius?: number @@ -54,6 +58,7 @@ export function ImageContextMenu({ preview={{ type: 'image', uri: fullsizeUri, + thumbUri, aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1, }} borderRadius={borderRadius} diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 06781c90e1..9f1510639e 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -80,6 +80,7 @@ export function ImageEmbed({ onPreviewPress(0)}> diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 1f2056c466..a1089d2c27 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -426,6 +426,7 @@ function GalleryImage({ aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}> diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index 2f694ddf52..a44d275a17 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -66,6 +66,7 @@ export function GalleryItem({ onPreviewPress(index) : undefined