Skip to content
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,60 @@ import StreamChat
import SwiftUI

/// Container showing the quoted message view with the user avatar.
struct QuotedMessageViewContainer<Factory: ViewFactory>: View {
private let avatarSize: CGFloat = 24
public struct QuotedMessageViewContainer<Factory: ViewFactory>: 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<String?>,
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
)
}
}
Expand All @@ -62,14 +82,13 @@ public struct QuotedMessageView<Factory: ViewFactory>: 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
}
Expand All @@ -78,73 +97,26 @@ public struct QuotedMessageView<Factory: ViewFactory>: 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(
Expand Down Expand Up @@ -174,6 +146,125 @@ public struct QuotedMessageView<Factory: ViewFactory>: 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<Factory: ViewFactory>: 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
Expand Down
9 changes: 9 additions & 0 deletions Sources/StreamChatSwiftUI/DefaultViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/StreamChatSwiftUI/ViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,18 @@ public protocol ViewFactory: AnyObject {
scrolledId: Binding<String?>
) -> 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.
Expand Down
Loading
Loading