Skip to content

Commit 8829152

Browse files
Improve customization of quoted message view (#1056)
* Allow changing the attachment size and author avatar size of quoted message view * Add new `QuotedMessageContentView` * Allow customizing the `QuotedMessageContentView` * Add test coverage to custom quoted avatar sizes * Remove unnecessary option * Add test coverage to custom quoted content view * Update CHANGELOG.md * Fix linter * [CI] Snapshots (#1057) * Move quotedMessage inside the `QuotedMessageContentViewOptions` --------- Co-authored-by: Stream SDK Bot <[email protected]>
1 parent c4d7df8 commit 8829152

File tree

9 files changed

+414
-74
lines changed

9 files changed

+414
-74
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6-
### 🔄 Changed
6+
### ✅ Added
7+
- Expose `QuotedMessageViewContainer` [#1056](https://github.com/GetStream/stream-chat-swiftui/pull/1056)
8+
- Add `QuotedMessageContentView` and `ViewFactory.makeQuotedMessageContentView()` [#1056](https://github.com/GetStream/stream-chat-swiftui/pull/1056)
9+
- Allow customizing the attachment size and avatar size of the quoted message view [#1056](https://github.com/GetStream/stream-chat-swiftui/pull/1056)
710

811
# [4.93.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.93.0)
912
_November 18, 2025_

Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift

Lines changed: 164 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,60 @@ import StreamChat
66
import SwiftUI
77

88
/// Container showing the quoted message view with the user avatar.
9-
struct QuotedMessageViewContainer<Factory: ViewFactory>: View {
10-
private let avatarSize: CGFloat = 24
9+
public struct QuotedMessageViewContainer<Factory: ViewFactory>: View {
10+
public var factory: Factory
11+
public var quotedMessage: ChatMessage
12+
public var fillAvailableSpace: Bool
13+
public var forceLeftToRight: Bool
14+
@Binding public var scrolledId: String?
15+
public let attachmentSize: CGSize
16+
public let quotedAuthorAvatarSize: CGSize
1117

12-
var factory: Factory
13-
var quotedMessage: ChatMessage
14-
var fillAvailableSpace: Bool
15-
var forceLeftToRight = false
16-
@Binding var scrolledId: String?
18+
public init(
19+
factory: Factory,
20+
quotedMessage: ChatMessage,
21+
fillAvailableSpace: Bool,
22+
forceLeftToRight: Bool = false,
23+
scrolledId: Binding<String?>,
24+
attachmentSize: CGSize = CGSize(width: 36, height: 36),
25+
quotedAuthorAvatarSize: CGSize = CGSize(width: 24, height: 24)
26+
) {
27+
self.factory = factory
28+
self.quotedMessage = quotedMessage
29+
self.fillAvailableSpace = fillAvailableSpace
30+
self.forceLeftToRight = forceLeftToRight
31+
_scrolledId = scrolledId
32+
self.attachmentSize = attachmentSize
33+
self.quotedAuthorAvatarSize = quotedAuthorAvatarSize
34+
}
1735

18-
var body: some View {
36+
public var body: some View {
1937
HStack(alignment: .bottom) {
2038
if !quotedMessage.isSentByCurrentUser || forceLeftToRight {
2139
factory.makeQuotedMessageAvatarView(
2240
for: quotedMessage.authorDisplayInfo,
23-
size: CGSize(width: avatarSize, height: avatarSize)
41+
size: quotedAuthorAvatarSize
2442
)
2543

2644
QuotedMessageView(
2745
factory: factory,
2846
quotedMessage: quotedMessage,
2947
fillAvailableSpace: fillAvailableSpace,
30-
forceLeftToRight: forceLeftToRight
48+
forceLeftToRight: forceLeftToRight,
49+
attachmentSize: attachmentSize
3150
)
3251
} else {
3352
QuotedMessageView(
3453
factory: factory,
3554
quotedMessage: quotedMessage,
3655
fillAvailableSpace: fillAvailableSpace,
37-
forceLeftToRight: forceLeftToRight
56+
forceLeftToRight: forceLeftToRight,
57+
attachmentSize: attachmentSize
3858
)
3959

4060
factory.makeQuotedMessageAvatarView(
4161
for: quotedMessage.authorDisplayInfo,
42-
size: CGSize(width: avatarSize, height: avatarSize)
62+
size: quotedAuthorAvatarSize
4363
)
4464
}
4565
}
@@ -62,14 +82,13 @@ public struct QuotedMessageView<Factory: ViewFactory>: View {
6282
@Injected(\.fonts) private var fonts
6383
@Injected(\.colors) private var colors
6484
@Injected(\.utils) private var utils
65-
66-
private let attachmentWidth: CGFloat = 36
6785

6886
public var factory: Factory
6987
public var quotedMessage: ChatMessage
7088
public var fillAvailableSpace: Bool
7189
public var forceLeftToRight: Bool
72-
90+
public let attachmentSize: CGSize
91+
7392
private var messageTypeResolver: MessageTypeResolving {
7493
utils.messageTypeResolver
7594
}
@@ -78,73 +97,26 @@ public struct QuotedMessageView<Factory: ViewFactory>: View {
7897
factory: Factory,
7998
quotedMessage: ChatMessage,
8099
fillAvailableSpace: Bool,
81-
forceLeftToRight: Bool
100+
forceLeftToRight: Bool,
101+
attachmentSize: CGSize = CGSize(width: 36, height: 36)
82102
) {
83103
self.factory = factory
84104
self.quotedMessage = quotedMessage
85105
self.fillAvailableSpace = fillAvailableSpace
86106
self.forceLeftToRight = forceLeftToRight
107+
self.attachmentSize = attachmentSize
87108
}
88109

89110
public var body: some View {
90111
HStack(alignment: .top) {
91-
if !quotedMessage.attachmentCounts.isEmpty {
92-
ZStack {
93-
if messageTypeResolver.hasCustomAttachment(message: quotedMessage) {
94-
factory.makeCustomAttachmentQuotedView(for: quotedMessage)
95-
} else if hasVoiceAttachments {
96-
VoiceRecordingPreview(voiceAttachment: quotedMessage.voiceRecordingAttachments[0].payload)
97-
} else if !quotedMessage.imageAttachments.isEmpty {
98-
LazyLoadingImage(
99-
source: MediaAttachment(url: quotedMessage.imageAttachments[0].imageURL, type: .image),
100-
width: attachmentWidth,
101-
height: attachmentWidth,
102-
resize: false
103-
)
104-
} else if !quotedMessage.giphyAttachments.isEmpty {
105-
LazyGiphyView(
106-
source: quotedMessage.giphyAttachments[0].previewURL,
107-
width: attachmentWidth
108-
)
109-
} else if !quotedMessage.fileAttachments.isEmpty {
110-
Image(uiImage: filePreviewImage(for: quotedMessage.fileAttachments[0].assetURL))
111-
} else if !quotedMessage.videoAttachments.isEmpty {
112-
VideoAttachmentView(
113-
attachment: quotedMessage.videoAttachments[0],
114-
message: quotedMessage,
115-
width: attachmentWidth,
116-
ratio: 1.0,
117-
cornerRadius: 0
118-
)
119-
} else if !quotedMessage.linkAttachments.isEmpty {
120-
LazyImage(
121-
imageURL: quotedMessage.linkAttachments[0].previewURL ?? quotedMessage.linkAttachments[0]
122-
.originalURL
123-
)
124-
.onDisappear(.cancel)
125-
.processors([ImageProcessors.Resize(width: attachmentWidth)])
126-
.priority(.high)
127-
}
128-
}
129-
.frame(width: hasVoiceAttachments ? nil : attachmentWidth, height: attachmentWidth)
130-
.aspectRatio(1, contentMode: .fill)
131-
.clipShape(RoundedRectangle(cornerRadius: 8))
132-
.allowsHitTesting(false)
133-
} else if let poll = quotedMessage.poll, !quotedMessage.isDeleted {
134-
Text("📊 \(poll.name)")
135-
}
136-
137-
if !hasVoiceAttachments {
138-
Text(textForMessage)
139-
.foregroundColor(textColor(for: quotedMessage))
140-
.lineLimit(3)
141-
.font(fonts.footnote)
142-
.accessibility(identifier: "quotedMessageText")
143-
}
144-
145-
if fillAvailableSpace {
146-
Spacer()
147-
}
112+
factory.makeQuotedMessageContentView(
113+
options: QuotedMessageContentViewOptions(
114+
quotedMessage: quotedMessage,
115+
fillAvailableSpace: fillAvailableSpace,
116+
forceLeftToRight: forceLeftToRight,
117+
attachmentSize: attachmentSize
118+
)
119+
)
148120
}
149121
.id(quotedMessage.messageId)
150122
.padding(
@@ -174,6 +146,125 @@ public struct QuotedMessageView<Factory: ViewFactory>: View {
174146
colors.quotedMessageBackgroundCurrentUser : colors.quotedMessageBackgroundOtherUser
175147
return color
176148
}
149+
150+
private var hasVoiceAttachments: Bool {
151+
!quotedMessage.voiceRecordingAttachments.isEmpty
152+
}
153+
}
154+
155+
/// Options for configuring the quoted message content view.
156+
public struct QuotedMessageContentViewOptions {
157+
/// The quoted message to display.
158+
public let quotedMessage: ChatMessage
159+
/// Whether the quoted container should take all the available space.
160+
public let fillAvailableSpace: Bool
161+
/// Whether to force left to right layout.
162+
public let forceLeftToRight: Bool
163+
/// The size of the attachment preview.
164+
public let attachmentSize: CGSize
165+
166+
public init(
167+
quotedMessage: ChatMessage,
168+
fillAvailableSpace: Bool,
169+
forceLeftToRight: Bool,
170+
attachmentSize: CGSize = CGSize(width: 36, height: 36)
171+
) {
172+
self.quotedMessage = quotedMessage
173+
self.fillAvailableSpace = fillAvailableSpace
174+
self.forceLeftToRight = forceLeftToRight
175+
self.attachmentSize = attachmentSize
176+
}
177+
}
178+
179+
/// The quoted message content view.
180+
///
181+
/// It is the view that is embedded in quoted message bubble view.
182+
public struct QuotedMessageContentView<Factory: ViewFactory>: View {
183+
@Environment(\.channelTranslationLanguage) var translationLanguage
184+
185+
@Injected(\.images) private var images
186+
@Injected(\.fonts) private var fonts
187+
@Injected(\.colors) private var colors
188+
@Injected(\.utils) private var utils
189+
190+
public var factory: Factory
191+
public var options: QuotedMessageContentViewOptions
192+
193+
private var quotedMessage: ChatMessage {
194+
options.quotedMessage
195+
}
196+
197+
private var messageTypeResolver: MessageTypeResolving {
198+
utils.messageTypeResolver
199+
}
200+
201+
public init(
202+
factory: Factory,
203+
options: QuotedMessageContentViewOptions
204+
) {
205+
self.factory = factory
206+
self.options = options
207+
}
208+
209+
public var body: some View {
210+
if !quotedMessage.attachmentCounts.isEmpty {
211+
ZStack {
212+
if messageTypeResolver.hasCustomAttachment(message: quotedMessage) {
213+
factory.makeCustomAttachmentQuotedView(for: quotedMessage)
214+
} else if hasVoiceAttachments {
215+
VoiceRecordingPreview(voiceAttachment: quotedMessage.voiceRecordingAttachments[0].payload)
216+
} else if !quotedMessage.imageAttachments.isEmpty {
217+
LazyLoadingImage(
218+
source: MediaAttachment(url: quotedMessage.imageAttachments[0].imageURL, type: .image),
219+
width: options.attachmentSize.width,
220+
height: options.attachmentSize.height,
221+
resize: false
222+
)
223+
} else if !quotedMessage.giphyAttachments.isEmpty {
224+
LazyGiphyView(
225+
source: quotedMessage.giphyAttachments[0].previewURL,
226+
width: options.attachmentSize.width
227+
)
228+
} else if !quotedMessage.fileAttachments.isEmpty {
229+
Image(uiImage: filePreviewImage(for: quotedMessage.fileAttachments[0].assetURL))
230+
} else if !quotedMessage.videoAttachments.isEmpty {
231+
VideoAttachmentView(
232+
attachment: quotedMessage.videoAttachments[0],
233+
message: quotedMessage,
234+
width: options.attachmentSize.width,
235+
ratio: 1.0,
236+
cornerRadius: 0
237+
)
238+
} else if !quotedMessage.linkAttachments.isEmpty {
239+
LazyImage(
240+
imageURL: quotedMessage.linkAttachments[0].previewURL ?? quotedMessage.linkAttachments[0]
241+
.originalURL
242+
)
243+
.onDisappear(.cancel)
244+
.processors([ImageProcessors.Resize(width: options.attachmentSize.width)])
245+
.priority(.high)
246+
}
247+
}
248+
.frame(width: hasVoiceAttachments ? nil : options.attachmentSize.width, height: options.attachmentSize.height)
249+
.aspectRatio(1, contentMode: .fill)
250+
.clipShape(RoundedRectangle(cornerRadius: 8))
251+
.allowsHitTesting(false)
252+
} else if let poll = quotedMessage.poll, !quotedMessage.isDeleted {
253+
Text("📊 \(poll.name)")
254+
}
255+
256+
if !hasVoiceAttachments {
257+
Text(textForMessage)
258+
.foregroundColor(textColor(for: quotedMessage))
259+
.lineLimit(3)
260+
.font(fonts.footnote)
261+
.accessibility(identifier: "quotedMessageText")
262+
}
263+
264+
if options.fillAvailableSpace {
265+
Spacer()
266+
}
267+
}
177268

178269
private func filePreviewImage(for url: URL) -> UIImage {
179270
let iconName = url.pathExtension

Sources/StreamChatSwiftUI/DefaultViewFactory.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,15 @@ extension ViewFactory {
998998
)
999999
}
10001000

1001+
public func makeQuotedMessageContentView(
1002+
options: QuotedMessageContentViewOptions
1003+
) -> some View {
1004+
QuotedMessageContentView(
1005+
factory: self,
1006+
options: options
1007+
)
1008+
}
1009+
10011010
public func makeCustomAttachmentQuotedView(for message: ChatMessage) -> some View {
10021011
EmptyView()
10031012
}

Sources/StreamChatSwiftUI/ViewFactory.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,18 @@ public protocol ViewFactory: AnyObject {
10151015
scrolledId: Binding<String?>
10161016
) -> QuotedMessageViewType
10171017

1018+
associatedtype QuotedMessageContentViewType: View
1019+
/// Creates the quoted message content view.
1020+
///
1021+
/// It is the view that is embedded in quoted message bubble view.
1022+
///
1023+
/// - Parameters:
1024+
/// - options: configuration options for the quoted message content view.
1025+
/// - Returns: view displayed in the quoted message content slot.
1026+
func makeQuotedMessageContentView(
1027+
options: QuotedMessageContentViewOptions
1028+
) -> QuotedMessageContentViewType
1029+
10181030
associatedtype CustomAttachmentQuotedViewType: View
10191031
/// Creates a quoted view for custom attachments. Returns `EmptyView` by default.
10201032
/// - Parameter message: the quoted message.

0 commit comments

Comments
 (0)