diff --git a/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift b/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift index 56bc101c7..8b0d0eb75 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift @@ -36,15 +36,24 @@ struct LoadableAvatarImage: View { } var body: some View { - LoadableImage(url: url, - size: avatarSize.scaledSize, - imageProvider: imageProvider) { image in - image - .scaledToFill() - } placeholder: { + avatar + .frame(width: frameSize, height: frameSize) + .clipShape(Circle()) + } + + @ViewBuilder + var avatar: some View { + if let url { + LoadableImage(url: url, + size: avatarSize.scaledSize, + imageProvider: imageProvider) { image in + image + .scaledToFill() + } placeholder: { + PlaceholderAvatarImage(name: name, contentID: contentID) + } + } else { PlaceholderAvatarImage(name: name, contentID: contentID) } - .frame(width: frameSize, height: frameSize) - .clipShape(Circle()) } } diff --git a/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift b/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift index 47f533104..569f92b49 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift @@ -14,18 +14,19 @@ // limitations under the License. // +import Kingfisher import SwiftUI -struct LoadableImage: View { - private let mediaSource: MediaSourceProxy? +struct LoadableImage: View, ImageDataProvider { + private let mediaSource: MediaSourceProxy private let blurhash: String? private let size: CGSize? private let imageProvider: ImageProviderProtocol? - private var transformer: (Image) -> TransformerView + private var transformer: (AnyView) -> TransformerView private let placeholder: () -> PlaceholderView - @State private var cachedImage: UIImage? + @StateObject private var contentLoader: ContentLoader /// A SwiftUI view that automatically fetches images /// It will try fetching the image from in-memory cache and if that's not available @@ -35,11 +36,11 @@ struct LoadableImage: View { /// - 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?, + init(mediaSource: MediaSourceProxy, blurhash: String? = nil, size: CGSize? = nil, imageProvider: ImageProviderProtocol?, - transformer: @escaping (Image) -> TransformerView = { $0 }, + transformer: @escaping (AnyView) -> TransformerView = { $0 }, placeholder: @escaping () -> PlaceholderView) { self.mediaSource = mediaSource self.blurhash = blurhash @@ -48,17 +49,17 @@ struct LoadableImage: View { self.transformer = transformer self.placeholder = placeholder + + _contentLoader = StateObject(wrappedValue: ContentLoader(mediaSource: mediaSource, size: size, imageProvider: imageProvider)) } - init(url: URL?, + init(url: URL, blurhash: String? = nil, size: CGSize? = nil, imageProvider: ImageProviderProtocol?, - transformer: @escaping (Image) -> TransformerView = { $0 }, + transformer: @escaping (AnyView) -> TransformerView = { $0 }, placeholder: @escaping () -> PlaceholderView) { - let mediaSource = url.map { MediaSourceProxy(url: $0, mimeType: nil) } - - self.init(mediaSource: mediaSource, + self.init(mediaSource: MediaSourceProxy(url: url, mimeType: nil), blurhash: blurhash, size: size, imageProvider: imageProvider, @@ -69,34 +70,101 @@ struct LoadableImage: View { var body: some View { let _ = Task { // Future improvement: Does guarding against a nil image prevent the image being updated when the URL changes? - guard image == nil, let mediaSource else { return } - - if case let .success(image) = await imageProvider?.loadImageFromSource(mediaSource, size: size) { - cachedImage = image + guard contentLoader.content == nil else { + return } + + await contentLoader.load() } ZStack { - if let image { + switch contentLoader.content { + case .image(let image): transformer( - Image(uiImage: image) - .resizable() + AnyView(Image(uiImage: image).resizable()) ) - } else 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)) { - transformer( - Image(uiImage: image) - .resizable() - ) - } else { - placeholder() + case .gifData: + transformer(AnyView(KFAnimatedImage(source: .provider(self)))) + case .none: + 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)) { + transformer(AnyView(Image(uiImage: image).resizable())) + } else { + placeholder() + } } } - .animation(.elementDefault, value: image) + .animation(.elementDefault, value: contentLoader.content) } - private var image: UIImage? { - cachedImage ?? imageProvider?.imageFromSource(mediaSource, size: size) + // 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 imageProvider: ImageProviderProtocol? + private let mediaSource: MediaSourceProxy + private let size: CGSize? + + @Published private var cachedContent: Content? + + var content: Content? { + if cachedContent != nil { + return cachedContent + } + + if let image = imageProvider?.imageFromSource(mediaSource) { + let isGIF = mediaSource.mimeType == "image/gif" + + if isGIF { + if let data = image.kf.data(format: .GIF) { + return .gifData(data) + } + } else { + return .image(image) + } + } + + return cachedContent + } + + init(mediaSource: MediaSourceProxy, size: CGSize?, imageProvider: ImageProviderProtocol?) { + self.mediaSource = mediaSource + self.size = size + self.imageProvider = imageProvider + } + + @MainActor + func load() async { + if isGIF { + if case let .success(data) = await imageProvider?.loadImageDataFromSource(mediaSource) { + cachedContent = .gifData(data) + } + } else { + if case let .success(image) = await imageProvider?.loadImageFromSource(mediaSource, size: size) { + cachedContent = .image(image) + } + } + } + + private var isGIF: Bool { + mediaSource.mimeType == "image/gif" } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index b7b38a4f2..a33bc91ea 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -25,12 +25,10 @@ struct ImageRoomTimelineView: View { TimelineStyler(timelineItem: timelineItem) { LoadableImage(mediaSource: timelineItem.source, blurhash: timelineItem.blurhash, - imageProvider: context.imageProvider) { image in - image.overlay { overlay } - } placeholder: { + imageProvider: context.imageProvider) { placeholder } - .frame(maxHeight: 300) + .frame(maxHeight: min(300, timelineItem.height ?? .infinity)) .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } } @@ -45,20 +43,6 @@ struct ImageRoomTimelineView: View { .frame(maxWidth: .infinity) } } - - @ViewBuilder - var overlay: some View { - if timelineItem.contentType == .gif { - Text(L10n.commonGif) - .font(.element.bodyBold) - .foregroundStyle(.primary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(.thinMaterial) - .cornerRadius(8) - .environment(\.colorScheme, .dark) - } - } } struct ImageRoomTimelineView_Previews: PreviewProvider { @@ -77,7 +61,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil)) + source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"))) ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, body: "Some other image", @@ -85,7 +69,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil)) + source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"))) ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, body: "Blurhashed image", @@ -93,7 +77,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil, + source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"), aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW", contentType: .gif)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift index f399cb70e..1e8b146bd 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift @@ -28,7 +28,7 @@ struct StickerRoomTimelineView: View { imageProvider: context.imageProvider) { placeholder } - .frame(maxHeight: 300) + .frame(maxHeight: min(300, timelineItem.height ?? .infinity)) .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } .accessibilityLabel(timelineItem.body) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift index 41151eaf3..c46f99866 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -23,7 +23,16 @@ struct VideoRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - LoadableImage(mediaSource: timelineItem.thumbnailSource, + thumbnail + .frame(maxHeight: min(300, timelineItem.height ?? .infinity)) + .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + } + } + + @ViewBuilder + var thumbnail: some View { + if let thumbnailSource = timelineItem.thumbnailSource { + LoadableImage(mediaSource: thumbnailSource, blurhash: timelineItem.blurhash, imageProvider: context.imageProvider) { imageView in imageView @@ -31,8 +40,8 @@ struct VideoRoomTimelineView: View { } placeholder: { placeholder } - .frame(maxHeight: 300) - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + } else { + playIcon } } diff --git a/ElementX/Sources/Services/Media/Provider/ImageProviderProtocol.swift b/ElementX/Sources/Services/Media/Provider/ImageProviderProtocol.swift index eae26b377..578e83039 100644 --- a/ElementX/Sources/Services/Media/Provider/ImageProviderProtocol.swift +++ b/ElementX/Sources/Services/Media/Provider/ImageProviderProtocol.swift @@ -20,6 +20,8 @@ protocol ImageProviderProtocol { func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result + + func loadImageDataFromSource(_ source: MediaSourceProxy) async -> Result } extension ImageProviderProtocol { diff --git a/ElementX/Sources/Services/Media/Provider/MediaProvider.swift b/ElementX/Sources/Services/Media/Provider/MediaProvider.swift index 679631301..d808748c4 100644 --- a/ElementX/Sources/Services/Media/Provider/MediaProvider.swift +++ b/ElementX/Sources/Services/Media/Provider/MediaProvider.swift @@ -79,6 +79,16 @@ struct MediaProvider: MediaProviderProtocol { } } + func loadImageDataFromSource(_ source: MediaSourceProxy) async -> Result { + do { + let imageData = try await mediaLoader.loadMediaContentForSource(source) + return .success(imageData) + } catch { + MXLog.error("Failed retrieving image with error: \(error)") + return .failure(.failedRetrievingImage) + } + } + // MARK: Files func loadFileFromSource(_ source: MediaSourceProxy) async -> Result { diff --git a/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift b/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift index c4a220d08..ceb3597b3 100644 --- a/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift +++ b/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift @@ -38,6 +38,15 @@ struct MockMediaProvider: MediaProviderProtocol { return .success(image) } + func loadImageDataFromSource(_ source: MediaSourceProxy) async -> Result { + guard let image = UIImage(systemName: "photo"), + let data = image.pngData() else { + fatalError() + } + + return .success(data) + } + func loadFileFromSource(_ source: MediaSourceProxy) async -> Result { .failure(.failedRetrievingFile) } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index 4ea028ed3..4bb8e9416 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -26,7 +26,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let sender: TimelineItemSender - let source: MediaSourceProxy? + let source: MediaSourceProxy var width: CGFloat? var height: CGFloat? diff --git a/UnitTests/Sources/LoggingTests.swift b/UnitTests/Sources/LoggingTests.swift index 5dce3c600..3147753d6 100644 --- a/UnitTests/Sources/LoggingTests.swift +++ b/UnitTests/Sources/LoggingTests.swift @@ -224,7 +224,8 @@ class LoggingTests: XCTestCase { timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", body: "ImageString", timestamp: "", isOutgoing: false, isEditable: false, - sender: .init(id: "sender"), source: nil) + sender: .init(id: "sender"), + source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif")) let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString", timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil)