// // Copyright 2023, 2024 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. // import Combine import Compound import Kingfisher import SwiftUI /// Used to configure animations enum LoadableImageMediaType: Equatable { /// An avatar (can be displayed anywhere within the app). case avatar /// An image displayed in the timeline. case timelineItem(uniqueID: TimelineItemIdentifier.UniqueID) /// Any other media (can be displayed anywhere within the app). case generic } struct LoadableImage: View { private let mediaSource: MediaSourceProxy? private let mediaType: LoadableImageMediaType private let blurhash: String? private let size: CGSize? private let mediaProvider: MediaProviderProtocol? private let transformer: (AnyView) -> TransformerView private let placeholder: () -> PlaceholderView /// A SwiftUI view that automatically fetches images /// It will try fetching the image from in-memory cache and if that's not available /// it will fire a task to load it through the image provider /// - Parameters: /// - mediaSource: the source of the image /// - blurhash: an optional blurhash /// - transformer: entry point for configuring the resulting image view /// - placeholder: a view to show while the image or blurhash are not available init(mediaSource: MediaSourceProxy, mediaType: LoadableImageMediaType = .generic, blurhash: String? = nil, size: CGSize? = nil, mediaProvider: MediaProviderProtocol?, transformer: @escaping (AnyView) -> TransformerView = { $0 }, placeholder: @escaping () -> PlaceholderView) { self.mediaSource = mediaSource self.mediaType = mediaType self.blurhash = blurhash self.size = size self.mediaProvider = mediaProvider self.transformer = transformer self.placeholder = placeholder } init(url: URL, mediaType: LoadableImageMediaType = .generic, blurhash: String? = nil, size: CGSize? = nil, mediaProvider: MediaProviderProtocol?, transformer: @escaping (AnyView) -> TransformerView = { $0 }, placeholder: @escaping () -> PlaceholderView) { mediaSource = try? MediaSourceProxy(url: url, mimeType: nil) self.mediaType = mediaType self.blurhash = blurhash self.size = size self.mediaProvider = mediaProvider self.transformer = transformer self.placeholder = placeholder } var body: some View { if let mediaSource { LoadableImageContent(mediaSource: mediaSource, mediaType: mediaType, blurhash: blurhash, size: size, mediaProvider: mediaProvider, transformer: transformer, placeholder: placeholder) .id(stableMediaIdentifier) } else { placeholder() } } private var stableMediaIdentifier: String? { switch mediaType { case .timelineItem(let uniqueID): // Consider media for the same item to be the same view uniqueID.value default: // Binds the lifecycle of the LoadableImage to the associated URL. // This fixes the problem of the cache returning old values after a change in the URL. mediaSource?.url.absoluteString } } } private struct LoadableImageContent: View, ImageDataProvider { @Environment(\.shouldAutomaticallyLoadImages) private var loadAutomatically private let mediaSource: MediaSourceProxy private let mediaType: LoadableImageMediaType private let blurhash: String? private let transformer: (AnyView) -> TransformerView private let placeholder: () -> PlaceholderView @StateObject private var contentLoader: ContentLoader @State private var loadManually = false init(mediaSource: MediaSourceProxy, mediaType: LoadableImageMediaType, blurhash: String? = nil, size: CGSize? = nil, mediaProvider: MediaProviderProtocol?, transformer: @escaping (AnyView) -> TransformerView, placeholder: @escaping () -> PlaceholderView) { assert(mediaProvider != nil, "Missing image provider, make sure one has been supplied to the view model.") self.mediaSource = mediaSource self.mediaType = mediaType self.blurhash = blurhash self.transformer = transformer self.placeholder = placeholder _contentLoader = StateObject(wrappedValue: ContentLoader(mediaSource: mediaSource, size: size, mediaProvider: mediaProvider)) } var shouldRender: Bool { loadAutomatically || loadManually } var body: some View { ZStack { switch (contentLoader.content, shouldRender) { case (.image(let image), true): transformer( AnyView(Image(uiImage: image).resizable()) ) case (.gifData, true): transformer(AnyView(KFAnimatedImage(source: .provider(self)))) case (.none, _), (_, false): if let blurHashView { if shouldRender { transformer(blurHashView) } else { blurHashView } } else { placeholder().overlay { placeholderOverlay } } } } .animation(mediaType == .avatar ? .noAnimation : .elementDefault, value: contentLoader.content) .animation(.elementDefault, value: loadManually) .task(id: mediaSource.url.absoluteString + "\(shouldRender)") { guard shouldRender, contentLoader.content == nil else { return } await contentLoader.load() } .onDisappear { guard contentLoader.content == nil else { return } contentLoader.cancel() } } // Note: Returns `AnyView` as this is what `transformer` expects. var blurHashView: AnyView? { if let blurhash, // Build a small blurhash image so that it's fast let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) { return AnyView(Image(uiImage: image).resizable().overlay { blurHashOverlay }) } else { return nil } } // MARK: - Overlays @ViewBuilder var placeholderOverlay: some View { switch mediaType { case .avatar, .generic: EmptyView() case .timelineItem: if shouldRender { ProgressView(L10n.commonLoading) .frame(maxWidth: .infinity) } else { loadManuallyButton } } } @ViewBuilder var blurHashOverlay: some View { if !shouldRender { loadManuallyButton } } var loadManuallyButton: some View { ZStack { Color.black.opacity(0.6) .contentShape(.rect) .onTapGesture { /* Empty gesture to block the `mediaTapped` action */ } // Don't use a real Button as it sometimes triggers simultaneously with the long press gesture. Text(L10n.actionShow) .font(.compound.bodyLGSemibold) .foregroundStyle(.compound.textOnSolidPrimary) .padding(.horizontal, 16) .padding(.vertical, 4) .overlay { Capsule() .stroke(lineWidth: 1) .foregroundStyle(.compound.borderInteractiveSecondary) } .contentShape(.capsule) .onTapGesture { loadManually = true } .environment(\.colorScheme, .light) } } // MARK: - ImageDataProvider var cacheKey: String { mediaSource.url.absoluteString } func data(handler: @escaping (Result) -> Void) { guard case let .gifData(data) = contentLoader.content else { fatalError("Shouldn't reach this point without any gif data") } handler(.success(data)) } } private class ContentLoader: ObservableObject { enum Content: Equatable { case image(UIImage) case gifData(Data) } private let mediaProvider: MediaProviderProtocol? private let mediaSource: MediaSourceProxy private let size: CGSize? private var imageLoadingCancellable: AnyCancellable? @Published private var cachedContent: Content? var content: Content? { if cachedContent != nil { return cachedContent } if isGIF { if let image = mediaProvider?.imageFromSource(mediaSource), let data = image.kf.data(format: .GIF) { return .gifData(data) } } else if let image = mediaProvider?.imageFromSource(mediaSource, size: size) { return .image(image) } return cachedContent } init(mediaSource: MediaSourceProxy, size: CGSize?, mediaProvider: MediaProviderProtocol?) { self.mediaSource = mediaSource self.size = size self.mediaProvider = mediaProvider } @MainActor func load() async { if isGIF { if case let .success(data) = await mediaProvider?.loadImageDataFromSource(mediaSource) { cachedContent = .gifData(data) } } else { guard let task = mediaProvider?.loadImageRetryingOnReconnection(mediaSource, size: size) else { MXLog.error("Failed loading image, invalid reconnection retry task.") return } imageLoadingCancellable = task.asCancellable() if let image = try? await task.value { cachedContent = .image(image) } } } func cancel() { imageLoadingCancellable?.cancel() } private var isGIF: Bool { mediaSource.mimeType == "image/gif" } } extension EnvironmentValues { /// Whether or not images should be loaded inside `LoadableImage` without a user interaction. @Entry var shouldAutomaticallyLoadImages = true } // MARK: - Previews struct LoadableImage_Previews: PreviewProvider, TestablePreview { static let mediaProvider = makeMediaProvider() static let loadingMediaProvider = makeMediaProvider(isLoading: true) static var previews: some View { LazyVGrid(columns: [.init(.adaptive(minimum: 110, maximum: 110))], spacing: 24) { LoadableImage(url: "mxc://wherever/1234", mediaType: .timelineItem(uniqueID: .init("id")), mediaProvider: mediaProvider, placeholder: placeholder) .layout(title: "Loaded") LoadableImage(url: "mxc://wherever/2345", mediaType: .timelineItem(uniqueID: .init("id")), blurhash: "KpE4oyayR5|GbHb];3j@of", mediaProvider: mediaProvider, placeholder: placeholder) .layout(title: "Hidden (blurhash)", hideTimelineMedia: true) LoadableImage(url: "mxc://wherever/3456", mediaType: .timelineItem(uniqueID: .init("id")), mediaProvider: mediaProvider, placeholder: placeholder) .layout(title: "Hidden (placeholder)", hideTimelineMedia: true) LoadableImage(url: "mxc://wherever/4567", mediaType: .timelineItem(uniqueID: .init("id")), blurhash: "KbLM^j]q$jT|EfR-3rtjXk", mediaProvider: loadingMediaProvider, placeholder: placeholder) .layout(title: "Loading (blurhash)") LoadableImage(url: "mxc://wherever/5678", mediaType: .timelineItem(uniqueID: .init("id")), mediaProvider: loadingMediaProvider, placeholder: placeholder) .layout(title: "Loading (placeholder)") LoadableImage(url: "mxc://wherever/6789", mediaType: .avatar, mediaProvider: loadingMediaProvider, placeholder: placeholder) .layout(title: "Loading (avatar)") LoadableImage(url: "mxc://wherever/345", mediaType: .timelineItem(uniqueID: .init("id")), blurhash: "KbLM^j]q$jT|EfR-3rtjXk", mediaProvider: mediaProvider, transformer: transformer, placeholder: placeholder) .layout(title: "Loaded (transformer)") LoadableImage(url: "mxc://wherever/345", mediaType: .timelineItem(uniqueID: .init("id")), blurhash: "KbLM^j]q$jT|EfR-3rtjXk", mediaProvider: loadingMediaProvider, transformer: transformer, placeholder: placeholder) .layout(title: "Loading (transformer)") LoadableImage(url: "mxc://wherever/234", mediaType: .timelineItem(uniqueID: .init("id")), blurhash: "KbLM^j]q$jT|EfR-3rtjXk", mediaProvider: mediaProvider, transformer: transformer, placeholder: placeholder) .layout(title: "Hidden (transformer)", hideTimelineMedia: true) } } static func placeholder() -> some View { Color.compound._bgBubbleIncoming } static func transformer(_ view: AnyView) -> some View { view.overlay { Image(systemSymbol: .playCircleFill) .font(.largeTitle) .foregroundStyle(.compound.iconAccentPrimary) } } static func makeMediaProvider(isLoading: Bool = false) -> MediaProviderProtocol { let mediaProvider = MediaProviderMock(configuration: .init()) if isLoading { mediaProvider.imageFromSourceSizeClosure = { _, _ in nil } mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } mediaProvider.loadImageDataFromSourceClosure = { _ in .failure(.failedRetrievingImage) } mediaProvider.loadImageFromSourceSizeClosure = { _, _ in .failure(.failedRetrievingImage) } mediaProvider.loadThumbnailForSourceSourceSizeClosure = { _, _ in .failure(.failedRetrievingThumbnail) } mediaProvider.loadImageRetryingOnReconnectionSizeClosure = { _, _ in Task { throw MediaProviderError.failedRetrievingImage } } } return mediaProvider } } private extension View { func layout(title: String, hideTimelineMedia: Bool = false) -> some View { aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 20)) .overlay(alignment: .bottom) { Text(title) .font(.caption2) .offset(y: 16) .padding(.horizontal, -5) } .environment(\.shouldAutomaticallyLoadImages, !hideTimelineMedia) } }