Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### 🔄 Changed

# [4.94.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.94.0)
_December 02, 2025_

### ✅ Added
- Add the `maxGalleryAssetsCount` to the composer config [#1053](https://github.com/GetStream/stream-chat-swiftui/pull/1053)
- 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)
### 🐞 Fixed
- Fix channel list skipping some updates on iPad [#1059](https://github.com/GetStream/stream-chat-swiftui/pull/1059)

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

Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.93.0")
.package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.94.0")
],
targets: [
.target(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<p align="center">
<a href="https://sonarcloud.io/summary/new_code?id=GetStream_stream-chat-swiftui"><img src="https://sonarcloud.io/api/project_badges/measure?project=GetStream_stream-chat-swiftui&metric=coverage" /></a>

<img id="stream-chat-swiftui-label" alt="StreamChatSwiftUI" src="https://img.shields.io/badge/StreamChatSwiftUI-9.63%20MB-blue"/>
<img id="stream-chat-swiftui-label" alt="StreamChatSwiftUI" src="https://img.shields.io/badge/StreamChatSwiftUI-9.68%20MB-blue"/>
</p>

## SwiftUI StreamChat SDK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public struct ComposerConfig {
public var inputViewCornerRadius: CGFloat
public var inputFont: UIFont
public var gallerySupportedTypes: GallerySupportedTypes
public var maxGalleryAssetsCount: Int?
public var inputPaddingsConfig: PaddingsConfig
public var adjustMessageOnSend: (String) -> (String)
public var adjustMessageOnRead: (String) -> (String)
Expand All @@ -33,6 +34,7 @@ public struct ComposerConfig {
inputViewCornerRadius: CGFloat = 20,
inputFont: UIFont = UIFont.preferredFont(forTextStyle: .body),
gallerySupportedTypes: GallerySupportedTypes = .imagesAndVideo,
maxGalleryAssetsCount: Int? = nil,
inputPaddingsConfig: PaddingsConfig = .composerInput,
adjustMessageOnSend: @escaping (String) -> (String) = { $0 },
adjustMessageOnRead: @escaping (String) -> (String) = { $0 },
Expand All @@ -47,6 +49,7 @@ public struct ComposerConfig {
self.adjustMessageOnRead = adjustMessageOnRead
self.attachmentPayloadConverter = attachmentPayloadConverter
self.gallerySupportedTypes = gallerySupportedTypes
self.maxGalleryAssetsCount = maxGalleryAssetsCount
self.inputPaddingsConfig = inputPaddingsConfig
self.isVoiceRecordingEnabled = isVoiceRecordingEnabled
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,9 @@ open class MessageComposerViewModel: ObservableObject {
fetchOptions.predicate = predicate
}
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
if let maxGalleryAssetsCount = utils.composerConfig.maxGalleryAssetsCount {
fetchOptions.fetchLimit = maxGalleryAssetsCount
}
let assets = PHAsset.fetchAssets(with: fetchOptions)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.imageAssets = assets
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,25 @@ 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,
attachmentSize: attachmentSize
)
)
}
.id(quotedMessage.messageId)
.padding(
Expand Down Expand Up @@ -174,6 +145,121 @@ 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
/// The size of the attachment preview.
public let attachmentSize: CGSize

public init(
quotedMessage: ChatMessage,
fillAvailableSpace: Bool,
attachmentSize: CGSize = CGSize(width: 36, height: 36)
) {
self.quotedMessage = quotedMessage
self.fillAvailableSpace = fillAvailableSpace
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
Loading
Loading