diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d0fa89..95c139c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🔄 Changed - Show inline alert banner when encountering a failure while interacting with polls [#504](https://github.com/GetStream/stream-chat-swiftui/pull/504) +- Grouping image and video attachments in the same message [#525](https://github.com/GetStream/stream-chat-swiftui/pull/525) # [4.57.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.57.0) _June 07, 2024_ diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift index 692a3497..2296c128 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift @@ -115,7 +115,7 @@ struct ImageAttachmentContentView: View { galleryShown = true } label: { LazyLoadingImage( - source: imageAttachment.imageURL, + source: MediaAttachment(url: imageAttachment.imageURL, type: .image), width: itemWidth, height: itemWidth ) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift index 6bb999fb..4c0ade86 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift @@ -2,6 +2,7 @@ // Copyright © 2024 Stream.io Inc. All rights reserved. // +import AVKit import StreamChat import SwiftUI @@ -14,7 +15,7 @@ public struct GalleryView: View { @Injected(\.fonts) private var fonts @Injected(\.images) private var images - var imageAttachments: [ChatMessageImageAttachment] + var mediaAttachments: [MediaAttachment] var author: ChatUser @Binding var isShown: Bool @State private var selected: Int @@ -27,7 +28,34 @@ public struct GalleryView: View { isShown: Binding, selected: Int ) { - self.imageAttachments = imageAttachments + let mediaAttachments = imageAttachments.map { attachment in + let url: URL + if let state = attachment.uploadingState { + url = state.localFileURL + } else { + url = attachment.imageURL + } + return MediaAttachment( + url: url, + type: .image, + uploadingState: attachment.uploadingState + ) + } + self.init( + mediaAttachments: mediaAttachments, + author: author, + isShown: isShown, + selected: selected + ) + } + + init( + mediaAttachments: [MediaAttachment], + author: ChatUser, + isShown: Binding, + selected: Int + ) { + self.mediaAttachments = mediaAttachments self.author = author _isShown = isShown _selected = State(initialValue: selected) @@ -43,27 +71,34 @@ public struct GalleryView: View { ) TabView(selection: $selected) { - ForEach(0..: View { ) { ImageAttachmentView( message: message, + sources: sources, width: width ) { index in if message.localState == nil { @@ -63,7 +64,7 @@ public struct ImageAttachmentContainer: View { self.selectedIndex = 0 }) { GalleryView( - imageAttachments: message.imageAttachments, + mediaAttachments: sources, author: message.author, isShown: $galleryShown, selected: selectedIndex @@ -71,6 +72,36 @@ public struct ImageAttachmentContainer: View { } .accessibilityIdentifier("ImageAttachmentContainer") } + + private var sources: [MediaAttachment] { + let videoSources = message.videoAttachments.map { attachment in + let url: URL + if let state = attachment.uploadingState { + url = state.localFileURL + } else { + url = attachment.videoURL + } + return MediaAttachment( + url: url, + type: .video, + uploadingState: attachment.uploadingState + ) + } + let imageSources = message.imageAttachments.map { attachment in + let url: URL + if let state = attachment.uploadingState { + url = state.localFileURL + } else { + url = attachment.imageURL + } + return MediaAttachment( + url: url, + type: .image, + uploadingState: attachment.uploadingState + ) + } + return videoSources + imageSources + } } public struct AttachmentTextView: View { @@ -115,6 +146,7 @@ struct ImageAttachmentView: View { @Injected(\.utils) private var utils let message: ChatMessage + let sources: [MediaAttachment] let width: CGFloat var imageTapped: ((Int) -> Void)? = nil @@ -125,16 +157,6 @@ struct ImageAttachmentView: View { utils.imageCDN } - private var sources: [URL] { - message.imageAttachments.map { attachment in - if let state = attachment.uploadingState { - return state.localFileURL - } else { - return attachment.imageURL - } - } - } - var body: some View { Group { if sources.count == 1 { @@ -144,7 +166,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 0 ) - .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0]) + .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0].url) } else if sources.count == 2 { HStack(spacing: spacing) { MultiImageView( @@ -154,7 +176,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 0 ) - .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0]) + .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0].url) MultiImageView( source: sources[1], @@ -163,7 +185,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 1 ) - .withUploadingStateIndicator(for: uploadState(for: 1), url: sources[1]) + .withUploadingStateIndicator(for: uploadState(for: 1), url: sources[1].url) } } else if sources.count == 3 { HStack(spacing: spacing) { @@ -174,7 +196,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 0 ) - .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0]) + .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0].url) VStack(spacing: spacing) { MultiImageView( @@ -184,7 +206,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 1 ) - .withUploadingStateIndicator(for: uploadState(for: 1), url: sources[1]) + .withUploadingStateIndicator(for: uploadState(for: 1), url: sources[1].url) MultiImageView( source: sources[2], @@ -193,7 +215,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 2 ) - .withUploadingStateIndicator(for: uploadState(for: 2), url: sources[2]) + .withUploadingStateIndicator(for: uploadState(for: 2), url: sources[2].url) } } } else if sources.count > 3 { @@ -206,7 +228,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 0 ) - .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0]) + .withUploadingStateIndicator(for: uploadState(for: 0), url: sources[0].url) MultiImageView( source: sources[2], @@ -215,7 +237,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 2 ) - .withUploadingStateIndicator(for: uploadState(for: 2), url: sources[2]) + .withUploadingStateIndicator(for: uploadState(for: 2), url: sources[2].url) } VStack(spacing: spacing) { @@ -226,7 +248,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 1 ) - .withUploadingStateIndicator(for: uploadState(for: 1), url: sources[1]) + .withUploadingStateIndicator(for: uploadState(for: 1), url: sources[1].url) ZStack { MultiImageView( @@ -236,7 +258,7 @@ struct ImageAttachmentView: View { imageTapped: imageTapped, index: 3 ) - .withUploadingStateIndicator(for: uploadState(for: 3), url: sources[3]) + .withUploadingStateIndicator(for: uploadState(for: 3), url: sources[3].url) if notDisplayedImages > 0 { Color.black.opacity(0.4) @@ -265,12 +287,12 @@ struct ImageAttachmentView: View { } private func uploadState(for index: Int) -> AttachmentUploadingState? { - message.imageAttachments[index].uploadingState + sources[index].uploadingState } } struct SingleImageView: View { - let source: URL + let source: MediaAttachment let width: CGFloat var imageTapped: ((Int) -> Void)? = nil var index: Int? @@ -293,7 +315,7 @@ struct SingleImageView: View { } struct MultiImageView: View { - let source: URL + let source: MediaAttachment let width: CGFloat let height: CGFloat var imageTapped: ((Int) -> Void)? = nil @@ -318,7 +340,7 @@ struct LazyLoadingImage: View { @State private var image: UIImage? @State private var error: Error? - let source: URL + let source: MediaAttachment let width: CGFloat let height: CGFloat var resize: Bool = true @@ -354,27 +376,28 @@ struct LazyLoadingImage: View { ProgressView() } } + + if source.type == .video && width > 64 && source.uploadingState == nil { + VideoPlayIcon() + } } .onAppear { if image != nil { return } - utils.imageLoader.loadImage( - url: source, - imageCDN: utils.imageCDN, + source.generateThumbnail( resize: resize, - preferredSize: CGSize(width: width, height: height), - completion: { result in - switch result { - case let .success(image): - self.image = image - onImageLoaded(image) - case let .failure(error): - self.error = error - } + preferredSize: CGSize(width: width, height: height) + ) { result in + switch result { + case let .success(image): + self.image = image + onImageLoaded(image) + case let .failure(error): + self.error = error } - ) + } } } @@ -396,3 +419,37 @@ extension ChatMessage { .leading } } + +struct MediaAttachment { + @Injected(\.utils) var utils + + let url: URL + let type: MediaAttachmentType + var uploadingState: AttachmentUploadingState? + + func generateThumbnail( + resize: Bool, + preferredSize: CGSize, + completion: @escaping (Result) -> Void + ) { + if type == .image { + utils.imageLoader.loadImage( + url: url, + imageCDN: utils.imageCDN, + resize: resize, + preferredSize: preferredSize, + completion: completion + ) + } else if type == .video { + utils.videoPreviewLoader.loadPreviewForVideo( + at: url, + completion: completion + ) + } + } +} + +enum MediaAttachmentType { + case image + case video +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift index b7041f47..4d891977 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift @@ -80,7 +80,8 @@ public struct MessageView: View { ) } - if messageTypeResolver.hasVideoAttachment(message: message) { + if messageTypeResolver.hasVideoAttachment(message: message) + && !messageTypeResolver.hasImageAttachment(message: message) { factory.makeVideoAttachmentView( for: message, isFirst: isFirst, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift index 295eee1d..3803680a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift @@ -95,7 +95,7 @@ public struct QuotedMessageView: View { VoiceRecordingPreview(voiceAttachment: quotedMessage.voiceRecordingAttachments[0].payload) } else if !quotedMessage.imageAttachments.isEmpty { LazyLoadingImage( - source: quotedMessage.imageAttachments[0].imageURL, + source: MediaAttachment(url: quotedMessage.imageAttachments[0].imageURL, type: .image), width: attachmentWidth, height: attachmentWidth, resize: false diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift index 97ebb5b4..cf468f38 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift @@ -161,11 +161,7 @@ struct VideoAttachmentContentView: View { if width > 64 && attachment.uploadingState == nil { VStack { - Image(uiImage: images.playFilled) - .customizable() - .frame(width: 24) - .foregroundColor(.white) - .modifier(ShadowModifier()) + VideoPlayIcon() } .frame(width: width, height: width * ratio) .contentShape(Rectangle()) @@ -204,3 +200,17 @@ struct VideoAttachmentContentView: View { } } } + +struct VideoPlayIcon: View { + @Injected(\.images) var images + + var width: CGFloat = 24 + + var body: some View { + Image(uiImage: images.playFilled) + .customizable() + .frame(width: width) + .foregroundColor(.white) + .modifier(ShadowModifier()) + } +} diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift index 7c839310..ce10eda2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift @@ -160,6 +160,35 @@ class MessageView_Tests: StreamChatTestCase { // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + + func test_messageViewImage_snapshot3ImagesAndVideo() { + // Given + let imageMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "test message", + author: .mock(id: .unique), + attachments: [ + ChatChannelTestHelpers.imageAttachments[0], + ChatChannelTestHelpers.imageAttachments[0], + ChatChannelTestHelpers.imageAttachments[0], + ChatChannelTestHelpers.videoAttachments[0] + ] + ) + + // When + let view = MessageView( + factory: DefaultViewFactory.shared, + message: imageMessage, + contentWidth: defaultScreenSize.width, + isFirst: true, + scrolledId: .constant(nil) + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } func test_messageViewImage_snapshotQuoted() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewImage_snapshot3ImagesAndVideo.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewImage_snapshot3ImagesAndVideo.1.png new file mode 100644 index 00000000..da6ba584 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewImage_snapshot3ImagesAndVideo.1.png differ