diff --git a/CHANGELOG.md b/CHANGELOG.md index 123e67fc..091ab6a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +### ✅ Added +- Expose `QuotedMessageViewContainer` [#1056](https://github.com/GetStream/stream-chat-swiftui/pull/1056) +- Add `QuotedMessageContentView` and `ViewFactory.makeQuotedMessageContentView()` [#1056](https://github.com/GetStream/stream-chat-swiftui/pull/1056) +- Allow customizing the attachment size and avatar size of the quoted message view [#1056](https://github.com/GetStream/stream-chat-swiftui/pull/1056) # [4.93.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.93.0) _November 18, 2025_ diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift index 97847ae9..c9551327 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift @@ -6,40 +6,60 @@ import StreamChat import SwiftUI /// Container showing the quoted message view with the user avatar. -struct QuotedMessageViewContainer: View { - private let avatarSize: CGFloat = 24 +public struct QuotedMessageViewContainer: View { + public var factory: Factory + public var quotedMessage: ChatMessage + public var fillAvailableSpace: Bool + public var forceLeftToRight: Bool + @Binding public var scrolledId: String? + public let attachmentSize: CGSize + public let quotedAuthorAvatarSize: CGSize - var factory: Factory - var quotedMessage: ChatMessage - var fillAvailableSpace: Bool - var forceLeftToRight = false - @Binding var scrolledId: String? + public init( + factory: Factory, + quotedMessage: ChatMessage, + fillAvailableSpace: Bool, + forceLeftToRight: Bool = false, + scrolledId: Binding, + attachmentSize: CGSize = CGSize(width: 36, height: 36), + quotedAuthorAvatarSize: CGSize = CGSize(width: 24, height: 24) + ) { + self.factory = factory + self.quotedMessage = quotedMessage + self.fillAvailableSpace = fillAvailableSpace + self.forceLeftToRight = forceLeftToRight + _scrolledId = scrolledId + self.attachmentSize = attachmentSize + self.quotedAuthorAvatarSize = quotedAuthorAvatarSize + } - var body: some View { + public var body: some View { HStack(alignment: .bottom) { if !quotedMessage.isSentByCurrentUser || forceLeftToRight { factory.makeQuotedMessageAvatarView( for: quotedMessage.authorDisplayInfo, - size: CGSize(width: avatarSize, height: avatarSize) + size: quotedAuthorAvatarSize ) QuotedMessageView( factory: factory, quotedMessage: quotedMessage, fillAvailableSpace: fillAvailableSpace, - forceLeftToRight: forceLeftToRight + forceLeftToRight: forceLeftToRight, + attachmentSize: attachmentSize ) } else { QuotedMessageView( factory: factory, quotedMessage: quotedMessage, fillAvailableSpace: fillAvailableSpace, - forceLeftToRight: forceLeftToRight + forceLeftToRight: forceLeftToRight, + attachmentSize: attachmentSize ) factory.makeQuotedMessageAvatarView( for: quotedMessage.authorDisplayInfo, - size: CGSize(width: avatarSize, height: avatarSize) + size: quotedAuthorAvatarSize ) } } @@ -62,14 +82,13 @@ public struct QuotedMessageView: View { @Injected(\.fonts) private var fonts @Injected(\.colors) private var colors @Injected(\.utils) private var utils - - private let attachmentWidth: CGFloat = 36 public var factory: Factory public var quotedMessage: ChatMessage public var fillAvailableSpace: Bool public var forceLeftToRight: Bool - + public let attachmentSize: CGSize + private var messageTypeResolver: MessageTypeResolving { utils.messageTypeResolver } @@ -78,73 +97,26 @@ public struct QuotedMessageView: View { factory: Factory, quotedMessage: ChatMessage, fillAvailableSpace: Bool, - forceLeftToRight: Bool + forceLeftToRight: Bool, + attachmentSize: CGSize = CGSize(width: 36, height: 36) ) { self.factory = factory self.quotedMessage = quotedMessage self.fillAvailableSpace = fillAvailableSpace self.forceLeftToRight = forceLeftToRight + self.attachmentSize = attachmentSize } public var body: some View { HStack(alignment: .top) { - if !quotedMessage.attachmentCounts.isEmpty { - ZStack { - if messageTypeResolver.hasCustomAttachment(message: quotedMessage) { - factory.makeCustomAttachmentQuotedView(for: quotedMessage) - } else if hasVoiceAttachments { - VoiceRecordingPreview(voiceAttachment: quotedMessage.voiceRecordingAttachments[0].payload) - } else if !quotedMessage.imageAttachments.isEmpty { - LazyLoadingImage( - source: MediaAttachment(url: quotedMessage.imageAttachments[0].imageURL, type: .image), - width: attachmentWidth, - height: attachmentWidth, - resize: false - ) - } else if !quotedMessage.giphyAttachments.isEmpty { - LazyGiphyView( - source: quotedMessage.giphyAttachments[0].previewURL, - width: attachmentWidth - ) - } else if !quotedMessage.fileAttachments.isEmpty { - Image(uiImage: filePreviewImage(for: quotedMessage.fileAttachments[0].assetURL)) - } else if !quotedMessage.videoAttachments.isEmpty { - VideoAttachmentView( - attachment: quotedMessage.videoAttachments[0], - message: quotedMessage, - width: attachmentWidth, - ratio: 1.0, - cornerRadius: 0 - ) - } else if !quotedMessage.linkAttachments.isEmpty { - LazyImage( - imageURL: quotedMessage.linkAttachments[0].previewURL ?? quotedMessage.linkAttachments[0] - .originalURL - ) - .onDisappear(.cancel) - .processors([ImageProcessors.Resize(width: attachmentWidth)]) - .priority(.high) - } - } - .frame(width: hasVoiceAttachments ? nil : attachmentWidth, height: attachmentWidth) - .aspectRatio(1, contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .allowsHitTesting(false) - } else if let poll = quotedMessage.poll, !quotedMessage.isDeleted { - Text("📊 \(poll.name)") - } - - if !hasVoiceAttachments { - Text(textForMessage) - .foregroundColor(textColor(for: quotedMessage)) - .lineLimit(3) - .font(fonts.footnote) - .accessibility(identifier: "quotedMessageText") - } - - if fillAvailableSpace { - Spacer() - } + factory.makeQuotedMessageContentView( + options: QuotedMessageContentViewOptions( + quotedMessage: quotedMessage, + fillAvailableSpace: fillAvailableSpace, + forceLeftToRight: forceLeftToRight, + attachmentSize: attachmentSize + ) + ) } .id(quotedMessage.messageId) .padding( @@ -174,6 +146,125 @@ public struct QuotedMessageView: View { colors.quotedMessageBackgroundCurrentUser : colors.quotedMessageBackgroundOtherUser return color } + + private var hasVoiceAttachments: Bool { + !quotedMessage.voiceRecordingAttachments.isEmpty + } +} + +/// Options for configuring the quoted message content view. +public struct QuotedMessageContentViewOptions { + /// The quoted message to display. + public let quotedMessage: ChatMessage + /// Whether the quoted container should take all the available space. + public let fillAvailableSpace: Bool + /// Whether to force left to right layout. + public let forceLeftToRight: Bool + /// The size of the attachment preview. + public let attachmentSize: CGSize + + public init( + quotedMessage: ChatMessage, + fillAvailableSpace: Bool, + forceLeftToRight: Bool, + attachmentSize: CGSize = CGSize(width: 36, height: 36) + ) { + self.quotedMessage = quotedMessage + self.fillAvailableSpace = fillAvailableSpace + self.forceLeftToRight = forceLeftToRight + self.attachmentSize = attachmentSize + } +} + +/// The quoted message content view. +/// +/// It is the view that is embedded in quoted message bubble view. +public struct QuotedMessageContentView: View { + @Environment(\.channelTranslationLanguage) var translationLanguage + + @Injected(\.images) private var images + @Injected(\.fonts) private var fonts + @Injected(\.colors) private var colors + @Injected(\.utils) private var utils + + public var factory: Factory + public var options: QuotedMessageContentViewOptions + + private var quotedMessage: ChatMessage { + options.quotedMessage + } + + private var messageTypeResolver: MessageTypeResolving { + utils.messageTypeResolver + } + + public init( + factory: Factory, + options: QuotedMessageContentViewOptions + ) { + self.factory = factory + self.options = options + } + + public var body: some View { + if !quotedMessage.attachmentCounts.isEmpty { + ZStack { + if messageTypeResolver.hasCustomAttachment(message: quotedMessage) { + factory.makeCustomAttachmentQuotedView(for: quotedMessage) + } else if hasVoiceAttachments { + VoiceRecordingPreview(voiceAttachment: quotedMessage.voiceRecordingAttachments[0].payload) + } else if !quotedMessage.imageAttachments.isEmpty { + LazyLoadingImage( + source: MediaAttachment(url: quotedMessage.imageAttachments[0].imageURL, type: .image), + width: options.attachmentSize.width, + height: options.attachmentSize.height, + resize: false + ) + } else if !quotedMessage.giphyAttachments.isEmpty { + LazyGiphyView( + source: quotedMessage.giphyAttachments[0].previewURL, + width: options.attachmentSize.width + ) + } else if !quotedMessage.fileAttachments.isEmpty { + Image(uiImage: filePreviewImage(for: quotedMessage.fileAttachments[0].assetURL)) + } else if !quotedMessage.videoAttachments.isEmpty { + VideoAttachmentView( + attachment: quotedMessage.videoAttachments[0], + message: quotedMessage, + width: options.attachmentSize.width, + ratio: 1.0, + cornerRadius: 0 + ) + } else if !quotedMessage.linkAttachments.isEmpty { + LazyImage( + imageURL: quotedMessage.linkAttachments[0].previewURL ?? quotedMessage.linkAttachments[0] + .originalURL + ) + .onDisappear(.cancel) + .processors([ImageProcessors.Resize(width: options.attachmentSize.width)]) + .priority(.high) + } + } + .frame(width: hasVoiceAttachments ? nil : options.attachmentSize.width, height: options.attachmentSize.height) + .aspectRatio(1, contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .allowsHitTesting(false) + } else if let poll = quotedMessage.poll, !quotedMessage.isDeleted { + Text("📊 \(poll.name)") + } + + if !hasVoiceAttachments { + Text(textForMessage) + .foregroundColor(textColor(for: quotedMessage)) + .lineLimit(3) + .font(fonts.footnote) + .accessibility(identifier: "quotedMessageText") + } + + if options.fillAvailableSpace { + Spacer() + } + } private func filePreviewImage(for url: URL) -> UIImage { let iconName = url.pathExtension diff --git a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift index 348993bb..296dc9c7 100644 --- a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift +++ b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift @@ -998,6 +998,15 @@ extension ViewFactory { ) } + public func makeQuotedMessageContentView( + options: QuotedMessageContentViewOptions + ) -> some View { + QuotedMessageContentView( + factory: self, + options: options + ) + } + public func makeCustomAttachmentQuotedView(for message: ChatMessage) -> some View { EmptyView() } diff --git a/Sources/StreamChatSwiftUI/ViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory.swift index 0439b8d1..79e13aaf 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory.swift @@ -1015,6 +1015,18 @@ public protocol ViewFactory: AnyObject { scrolledId: Binding ) -> QuotedMessageViewType + associatedtype QuotedMessageContentViewType: View + /// Creates the quoted message content view. + /// + /// It is the view that is embedded in quoted message bubble view. + /// + /// - Parameters: + /// - options: configuration options for the quoted message content view. + /// - Returns: view displayed in the quoted message content slot. + func makeQuotedMessageContentView( + options: QuotedMessageContentViewOptions + ) -> QuotedMessageContentViewType + associatedtype CustomAttachmentQuotedViewType: View /// Creates a quoted view for custom attachments. Returns `EmptyView` by default. /// - Parameter message: the quoted message. diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift index 5aab0565..00db066f 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift @@ -113,4 +113,229 @@ class QuotedMessageView_Tests: StreamChatTestCase { // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + + // MARK: - Custom Size Tests + + func test_quotedMessageViewContainer_customAttachmentSize_snapshot() { + // Given + let message = ChatMessage.mock( + id: "test", + cid: .unique, + text: "Image attachment", + author: .mock(id: "test", name: "martin"), + attachments: [ + ChatMessageImageAttachment.mock( + id: .unique, + imageURL: .localYodaImage + ).asAnyAttachment + ] + ) + let customAttachmentSize = CGSize(width: 60, height: 60) + let view = QuotedMessageViewContainer( + factory: DefaultViewFactory.shared, + quotedMessage: message, + fillAvailableSpace: true, + scrolledId: .constant(nil), + attachmentSize: customAttachmentSize + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_quotedMessageViewContainer_customAvatarSize_snapshot() { + // Given + let customAvatarSize = CGSize(width: 40, height: 40) + let view = QuotedMessageViewContainer( + factory: DefaultViewFactory.shared, + quotedMessage: testMessage, + fillAvailableSpace: true, + scrolledId: .constant(nil), + quotedAuthorAvatarSize: customAvatarSize + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_quotedMessageView_customAttachmentSize_snapshot() { + // Given + let message = ChatMessage.mock( + id: "test", + cid: .unique, + text: "Image attachment", + author: .mock(id: "test", name: "martin"), + attachments: [ + ChatMessageImageAttachment.mock( + id: .unique, + imageURL: .localYodaImage + ).asAnyAttachment + ] + ) + let customAttachmentSize = CGSize(width: 50, height: 50) + let view = QuotedMessageView( + factory: DefaultViewFactory.shared, + quotedMessage: message, + fillAvailableSpace: true, + forceLeftToRight: true, + attachmentSize: customAttachmentSize + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_quotedMessageViewContainer_defaultSizes() { + // Given + let container = QuotedMessageViewContainer( + factory: DefaultViewFactory.shared, + quotedMessage: testMessage, + fillAvailableSpace: true, + scrolledId: .constant(nil) + ) + + // Then - Default sizes should be applied + XCTAssertEqual(container.attachmentSize, CGSize(width: 36, height: 36)) + XCTAssertEqual(container.quotedAuthorAvatarSize, CGSize(width: 24, height: 24)) + } + + func test_quotedMessageView_defaultAttachmentSize() { + // Given + let view = QuotedMessageView( + factory: DefaultViewFactory.shared, + quotedMessage: testMessage, + fillAvailableSpace: true, + forceLeftToRight: true + ) + + // Then - Default attachment size should be applied + XCTAssertEqual(view.attachmentSize, CGSize(width: 36, height: 36)) + } + + func test_quotedMessageView_customContentView_snapshot() { + // Given - Create a custom football game result attachment + let footballGamePayload = FootballGameAttachmentPayload( + homeTeam: "Benfica", + awayTeam: "Porto", + homeScore: 2, + awayScore: 0 + ) + + let customAttachment = ChatMessageAttachment( + id: .unique, + type: .init(rawValue: "football_game"), + payload: footballGamePayload, + downloadingState: nil, + uploadingState: nil + ).asAnyAttachment + + let message = ChatMessage.mock( + id: "test", + cid: .unique, + text: "Check out this game result!", + author: .mock(id: "test", name: "martin"), + attachments: [customAttachment] + ) + + let view = QuotedMessageViewContainer( + factory: CustomQuotedContentViewFactory.shared, + quotedMessage: message, + fillAvailableSpace: false, + scrolledId: .constant(nil) + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } +} + +// MARK: - Custom Football Game Attachment for Custom Quoted Message + +private struct FootballGameAttachmentPayload: AttachmentPayload { + let homeTeam: String + let awayTeam: String + let homeScore: Int + let awayScore: Int + + static let type: AttachmentType = .init(rawValue: "football_game") +} + +private class CustomQuotedContentViewFactory: ViewFactory { + @Injected(\.chatClient) var chatClient + + private init() {} + + static let shared = CustomQuotedContentViewFactory() + + func makeQuotedMessageContentView( + options: QuotedMessageContentViewOptions + ) -> some View { + Group { + if let footballGameAttachmentPayload = options.quotedMessage + .attachments(payloadType: FootballGameAttachmentPayload.self) + .first? + .payload { + // Show custom football game result view + FootballGameQuotedView(payload: footballGameAttachmentPayload) + } else { + // Fallback to default content view + QuotedMessageContentView( + factory: self, + options: options + ) + } + } + } +} + +private struct FootballGameQuotedView: View { + @Injected(\.colors) private var colors + @Injected(\.fonts) private var fonts + + let payload: FootballGameAttachmentPayload + + var body: some View { + HStack(spacing: 8) { + VStack(alignment: .center, spacing: 4) { + Text("⚽") + .font(.title2) + Text("Match") + .font(fonts.footnoteBold) + .foregroundColor(Color(colors.textLowEmphasis)) + } + + Divider() + .frame(height: 50) + + VStack(spacing: 8) { + HStack { + Text(payload.homeTeam) + .font(fonts.bodyBold) + .foregroundColor(Color(colors.text)) + Spacer() + Text("\(payload.homeScore)") + .font(fonts.title) + .foregroundColor(Color(colors.text)) + .frame(minWidth: 30) + } + + HStack { + Text(payload.awayTeam) + .font(fonts.bodyBold) + .foregroundColor(Color(colors.text)) + Spacer() + Text("\(payload.awayScore)") + .font(fonts.title) + .foregroundColor(Color(colors.text)) + .frame(minWidth: 30) + } + } + } + .padding(8) + .frame(minWidth: 200) + } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageViewContainer_customAttachmentSize_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageViewContainer_customAttachmentSize_snapshot.1.png new file mode 100644 index 00000000..91baed12 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageViewContainer_customAttachmentSize_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageViewContainer_customAvatarSize_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageViewContainer_customAvatarSize_snapshot.1.png new file mode 100644 index 00000000..2bb05ee4 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageViewContainer_customAvatarSize_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageView_customAttachmentSize_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageView_customAttachmentSize_snapshot.1.png new file mode 100644 index 00000000..fb6ef06d Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageView_customAttachmentSize_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageView_customContentView_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageView_customContentView_snapshot.1.png new file mode 100644 index 00000000..0b354263 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/QuotedMessageView_Tests/test_quotedMessageView_customContentView_snapshot.1.png differ